The Soviets* Landed First

Noiseless did not start as Noiseless. It started as ActiveSearch.
It belongs to a family of gems I write in the Rails naming tradition. ActiveCypher and ActionMCP are on RubyGems. Others get published when they finish their battle quarantine.
Noiseless started with one job: moving off Elasticsearch on AWS, which had started getting slow across my personal and professional projects. The data and the queries hadn’t changed, and the answers were slower. I suspect the slowness was there to make me migrate, and I can’t prove it.
What AWS has published since is less subtle. Elasticsearch older than 7.9 can’t run on AWS’s Graviton instances, and the newest instance families built for the service require OpenSearch 2.11 or later. In November 2024 AWS put a price on staying: old Elasticsearch versions would lose standard support and move to paid Extended Support, billed per instance hour on top of the instance. The current schedule makes that surcharge equal to the instance cost for Elasticsearch 7.8 and older, starting November 7, 2026.
Either way I was moving to OpenSearch, and I wanted a thin wrapper around the official gem to do it.
OpenSearch is a fork of Elasticsearch, so the migration should be trivial. Right?
The fork¶
In January 2021 Elastic announced that starting with 7.11, Elasticsearch and Kibana would move from Apache 2.0 to a dual SSPL and Elastic License. AWS forked the last Apache-licensed release, 7.10.2, and OpenSearch 1.0 went GA on July 12, 2021.
At the fork point the code was identical, and so was the REST API. On paper you change a URL and go home.
The guard¶
Three weeks after OpenSearch 1.0, on August 4, 2021, Elastic released version 7.14.0 of the elasticsearch gem. It added a product check. Before your first request goes out, the client calls GET / on the cluster and decides whether it approves of the answer.
This is the check, trimmed from lib/elasticsearch.rb in the 7.14.0 gem:
def verify_with_version_or_header(body, version, headers)
raise Elasticsearch::UnsupportedProductError if version.nil? || version < '6.0.0'
if version == '7.x-SNAPSHOT' || Gem::Version.new(version) >= Gem::Version.new('7.14-SNAPSHOT')
raise Elasticsearch::UnsupportedProductError unless headers['x-elastic-product'] == 'Elasticsearch'
# ...6.x branch elided...
elsif Gem::Version.new(version) >= Gem::Version.new('7.0.0') &&
Gem::Version.new(version) < Gem::Version.new('7.14-SNAPSHOT')
raise Elasticsearch::UnsupportedProductError unless body['tagline'] == YOU_KNOW_FOR_SEARCH
raise Elasticsearch::UnsupportedProductError.new(NOT_SUPPORTED_ELASTICSEARCH_WARNING) unless body.dig('version', 'build_flavor') == 'default'
end
end
OpenSearch 1.0 reports its own version, 1.0.0. The first line compares versions as plain strings, and since "1.0.0" sorts before "6.0.0", the client rejects OpenSearch as if it were Elasticsearch 5. OpenSearch’s compatibility setting makes it report 7.10.2 instead, which only moves the failure to the tagline: OpenSearch answers "The OpenSearch Project: https://opensearch.org/", and the client wants "You Know, for Search".
The last check is the most honest one. If the tagline matches but build_flavor is anything other than default, the client raises again. Elastic’s own Apache-licensed build of 7.10.2 reports build_flavor: "oss". So the official Elasticsearch gem refused to talk to the open source Elasticsearch that Elastic itself had published a few months earlier.
I ran the real 7.14.0 check against all of these, with a stub transport answering GET / the way each server does:
OpenSearch 1.0.0: Elasticsearch::UnsupportedProductError: The client noticed that the server is not Elasticsearch and we do not support this unknown product.
OpenSearch 1.0.0, compat mode (reports 7.10.2): Elasticsearch::UnsupportedProductError: The client noticed that the server is not Elasticsearch and we do not support this unknown product.
Elastic OSS 7.10.2 (Apache 2.0 build): Elasticsearch::UnsupportedProductError: The client noticed that the server is not a supported distribution of Elasticsearch.
Ruby was not singled out. The Python client got the same check, and when people objected in the pull request, Elastic locked the thread. Elastic’s Steve Gordon, explaining the same check in the .NET client, gave the reason: “to make this incompatibility explicit by failing fast to avoid consumers incorrectly assuming they are running in a supported configuration which is not tested and may not function as expected.”
Nobody changed anything and it broke¶
For Rails apps you didn’t even have to upgrade on purpose. elasticsearch-model doesn’t pin the client’s minor version, so a routine bundle update pulled in 7.14.0. Eight days after the release someone on AWS Elasticsearch Service opened an issue with a backtrace ending in elasticsearch-7.14.0/lib/elasticsearch.rb:85. Line 85 is the build_flavor check. Their cluster was Elasticsearch.
The ecosystem’s answer was to pin, and RubyGems still shows it. Version 7.13.3, the last release without the check, is the most downloaded version of the gem ever, at 27.1 million. The runner-up, 7.17.11, has 17.3 million and the check.
opensearch-ruby 1.0.0 arrived on December 6, 2021, forked from the last Elastic client that didn’t check, and it shipped with a check of its own. It calls GET / before your first request and raises its own UnsupportedProductError unless the server is OpenSearch, or Elasticsearch 6 or 7. For OpenSearch users, that solved the client. The Rails layer on top, elasticsearch-model and elasticsearch-rails, still depends on the elasticsearch gem. rubygems.org itself stayed on elasticsearch 7.10.1, below the check, until a pull request opened in April 2022 moved its search to opensearch-ruby. It merged in January 2023.
In August 2024 Elastic added the AGPL to its license options and called Elasticsearch open source again. The client kept the guard. elasticsearch 9.5.0, released August 4, 2026, no longer makes the extra GET / call, but it inspects the response to your first real request and raises UnsupportedProductError unless the x-elastic-product header says Elasticsearch.
I wanted Rails¶
opensearch-ruby is a Ruby client. I wanted Rails. The only Rails layer on offer was elasticsearch-model, which still pulled in the gem with the guard.
The first version of Noiseless was a synchronous wrapper around the official clients. I wrote it by hand in 2021, a year before ChatGPT. Under heavy traffic it wedged my servers and made them slow, and I couldn’t see why. The clients underneath were plain Ruby gems, and nothing they did showed up in the Rails log.
In 2024 I rewrote it with Claude 3, in a chat window, because Claude Code didn’t exist yet. The rewrite stopped wrapping anything and grew into its own library. It went async from the transport up, and every search and every index write goes through ActiveSupport::Notifications as a noiseless.* event, with a log subscriber that prints its duration next to your SQL in the development log.
The name¶
ActiveSearch was taken. RubyGems already had an active_search from 2013 and an activesearch from 2012. I wrote to both authors of activesearch and got a reply. One of the owners looked at my profile on rails/rails and agreed to transfer the gem to me, because I’m a Rails contributor.
Then, in the run-up to Rails World 2025, I found out that Basecamp had an internal Active Search of its own. Taking a name Basecamp already used inside would have made me a namespace locust, and I didn’t want that fight with the community. I left the transfer alone and renamed the project Noiseless.
I pushed noiseless 0.0.0 to RubyGems on July 10, 2025, to hold the name. The same day, AWS sent my account a verification request with a five-day deadline. On July 23 it deleted the account, and my work with it, because the code lived in CodeCommit on that account. The account came back in August, after one human at AWS stepped in.
Reserving a name isn’t the problem. Noiseless is a random name, and if someone asks me for it, I’ll hand it over. The problem would have been keeping activesearch, or grabbing rails_active_search, then pushing slop under it and forgetting it exists. Locustism is naming a gem FastRubyServer because the model never told you Puma exists.
What Noiseless is¶
Noiseless wraps nothing. Its gemspec has no dependency on elasticsearch or opensearch-ruby. It speaks HTTP to the cluster itself through async-http, on top of Samuel Williams’ async. Every execute returns an Async::Task, and execute_sync is there for code that wants to block. The HTTP transport has an idle timeout and a wall-clock deadline, so a search engine that stops answering, or answers one byte at a time, raises Noiseless::ConnectionError instead of holding your request forever.
It never asks the server who it is, so there is no product check to fail.
It only supports current Rails and Ruby. Version 0.8.0 requires Rails 8.1.4 and Ruby 4.0, the newest releases of both.
No search vendor sponsors Noiseless, and no vendor decides when it ships. It gets a release whenever a fix lands, and 0.8.0 is the tenth since March 28. OpenSearch 3.9.0 shipped on September 29, and Noiseless moved to it five days later. When Rails or an engine publishes a release candidate, I can build support and benchmark it before the final release, without waiting for anyone else’s client to catch up.
Queries are an AST. The builder records nodes into a tree, and each adapter compiles that tree for its own backend. The HTTP engines each get JSON in their own dialect. PostgreSQL gets an ActiveRecord scope, using pg_trgm and unaccent when the extensions are installed.
Why async¶
The world around search went async first. An LLM calling a search tool works against the endpoint’s timeout, and a long search killed the flow. The fix is to acknowledge the request right away and let the caller come back for the result. MCP calls that pattern tasks, since its 2025-11-25 spec. ActionMCP implements them: a tool opts in with task_support :optional, and the client polls tasks/get until it can fetch tasks/result.
The search inside that task is Noiseless. execute hands back an Async::Task as soon as the request leaves, and the wall-clock deadline guarantees the task ends. No official Ruby client hands you a task: client.search blocks until the cluster answers. Elastic’s gem does wrap Elasticsearch’s server-side async search, where you submit a query and poll for it by id. opensearch-ruby has nothing for OpenSearch’s equivalent.
Async doesn’t make a search faster. I measured Noiseless 0.8.0 against a stub server that answers every search after 100 ms, so the numbers show waiting and nothing else. The stub also has to answer GET / like an OpenSearch 3.9 node, or opensearch-ruby refuses to send the first search:
| 10 searches | 100 searches | |
|---|---|---|
opensearch-ruby 3.4.0, one after another | 1,066 ms | 10,658 ms |
| Noiseless, one after another | 1,055 ms | 10,653 ms |
opensearch-ruby, one thread per search | 114 ms | 159 ms |
Noiseless, one Async block | 110 ms | 752 ms |
Noiseless, one Async block, pool_limit: 100 | 108 ms | 139 ms |
One after another, the official client and Noiseless are the same. The 100 ms belongs to the cluster, and no Ruby library makes it shorter.
The 752 ms is deliberate. Noiseless 0.8.0 caps open connections at 16 per host by default, so a hundred searches go out in waves of sixteen instead of opening a hundred sockets against your cluster at once. Raise pool_limit: on the connection and the hundred finish in 139 ms, but only because the stub has no limit of its own. A real node runs a fixed number of searches at once. Against the same stub capped at seven parallel searches, every limit from 8 to 100 took about 1.55 seconds, and the extra searches just queued inside the cluster. For numbers against real engines, Noiseless has its own benchmark suite.
The difference is what waiting costs. Threads fan out just as well, but the threaded run needed a hundred threads for a hundred searches. Noiseless ran the same hundred as fibers on one thread:
results = Sync do
100.times.map { |i| Article::Search.new.text("query #{i}").execute }.map(&:wait)
end
In a fiber-based server like Falcon, a request waiting on search doesn’t hold a thread at all. That makes waiting cheap. It also makes waiting forever invisible, and Noiseless found that out in production this year.
A Rails app on Falcon started failing about 18 hours after every restart. The CPU sat near 1% while between half and all of the requests came back as 5xx errors. The database was calm. A search had no deadline. OpenSearch accepted the connection and stopped answering, and the fiber waiting on it parked forever. Enough parked fibers, and the reactor stopped making progress.
Noiseless 0.5.0 added an idle timeout in June, and the wedge came back twice. A cluster that trickles out a byte at a time resets an idle timer with every byte. Version 0.7.0 added a wall-clock deadline in September, 30 seconds by default, around the whole round trip. With that, plus a traffic-aware restart in the proxy in front of the app, the next stall cleared in about a minute without a human. The June outages had lasted hours.
The fix lives in the gem, so every app on 0.7.0 or later inherits it. Version 0.8.0 closes the gaps around it: waiting for a free connection now counts against the same deadline, and the PostgreSQL and Typesense adapters raise on a failed search instead of returning an empty result.
Every search is scoped¶
Other gems patch the model. Searchkick puts searchkick in your model. elasticsearch-model has you include Elasticsearch::Model. Either way you get one Article.search for the whole app.
Then the product needs different results for every role: guest, user, client, admin, moderator. That single search grows an if with 728 arms.
We are not savages. In Noiseless a search is its own class. You extend it by inheritance and compose it with concerns:
module Flagged
extend ActiveSupport::Concern
def flagged = filter(:flagged, true)
end
class Article::Search < Noiseless::Model
index_name "articles"
def text(query) = multi_match(query, %i[title content])
end
class Article::GuestSearch < Article::Search
def initialize
super
filter(:status, "published")
end
end
class Article::ModeratorSearch < Article::Search
include Flagged
end
The guest’s published filter is added in the constructor, so every guest search built with .new carries it. The moderator search never inherits it. Here is what those two searches compile to, for OpenSearch and for Typesense:
guest / opensearch: {"query":{"bool":{"must":[{"multi_match":{"query":"async","fields":["title","content"]}}],"filter":[{"term":{"status":"published"}}]}},"from":0,"size":20}
guest / typesense: {"q":"async","query_by":"title,content","filter_by":"status:=`published`","page":1,"per_page":20,"enable_highlight_v1":false}
mod / opensearch: {"query":{"bool":{"must":[{"multi_match":{"query":"async","fields":["title","content"]}}],"filter":[{"term":{"flagged":true}}]}},"from":0,"size":20}
mod / typesense: {"q":"async","query_by":"title,content","filter_by":"flagged:=true","page":1,"per_page":20,"enable_highlight_v1":false}
Since the AST doesn’t care where it runs, you can keep every backend connected at once and send the same search to each one:
search = Article::GuestSearch.new.text("async")
search.execute_sync(connection: :postgresql)
search.execute_sync(connection: :typesense)
search.execute_sync(connection: :opensearch)
That’s how you test a migration: run the same search on all three engines before you rewrite everything in Rust OpenSearch, only to find out your PostgreSQL query was faster.
Then Basecamp shipped it¶
In December 2025 DHH posted that they had Basecamp running on SQLite to work on Active Search. On September 23, 2026, Donal McBreen presented it at Rails World, and the night before, rails-active_search 0.1.0 went up on RubyGems.
My Twitter timeline greeted it as if Basecamp had discovered water on Mars. We’ve had wrappers for a long time. thinking-sphinx was on RubyGems in 2009 and shipped 6.0 this January. Searchkick has been around since 2013.
It is the pattern Noiseless started as. The gem includes a module into every ActiveRecord model, and the models that call has_search get a synchronous search method that goes through the official clients for Elasticsearch and OpenSearch. To its credit, it emits ActiveSupport::Notifications events, which my first version never did.
The talk abstract gives the reason for the design: 37signals uses it to switch search engines between the SaaS and self-hosted versions of Basecamp and Fizzy. For that job, one mixin is the right shape.
The gem shipped with ten store adapters in its first release, Solr among them, and its README lists the Solr version its test suite runs against. Outside the university libraries on Blacklight and Hyrax, I don’t know a Ruby shop that still runs Solr in 2026, and a passing test suite only proves the cases someone thought to write. Ten engines on day one reads like vibecoding to me. I used Claude for the Noiseless rewrite too, but I don’t want Claude to own my stack.
Ownership is the point of a dependency. When you adopt a gem, you expect the people behind it to know its code, and when an adapter breaks, you expect an expert in that engine to answer. If the answer comes from an LLM, you never needed the gem: you could have generated the adapter yourself. I’d have shipped the adapters 37signals runs, battle-tested in production, and nothing else.
If you want one mixin across many engines, use Basecamp’s gem. If you need several searches running at once inside one request, or search classes scoped per role, use Noiseless.
Noiseless has four adapters, each for an engine I’ve run in production. On Twitter the SQLite question came up. I don’t use SQLite, so my answer was an offer: write the adapter and become its maintainer.
Stefan Froelich, a developer in Ghana and the author of the Plutonium Rails framework, is building sqlite_search, full-text and vector search for ActiveRecord without leaving SQLite. It runs in production at Universal Chatbot and reached RubyGems on October 2. He proposed to build the Noiseless adapter on top of it. That’s who an adapter should come from: someone who runs the engine and will hit its edge cases, instead of vibe-accepting whatever a model contributes.
Find a flaw¶
Version 0.1.0 wasn’t the first time the code ran. Noiseless had been public on GitHub before, as seuros/activesearch. Friends pointed their Gemfile at that repository, and so did I. When I renamed the project, I squashed its history into Noiseless and pushed it to a new repository, which is why Noiseless’s history on GitHub starts with a single commit. Then I deleted the old repository.
On March 28, the day 0.1.0 went up on RubyGems, I posted Noiseless to r/rails. The top comment, “Pretty sure this post is AI generated,” got 31 points to the post’s 21. The only commenter who said they had read the repo ended at zero, my upvote included. In that thread I told one of them to find a flaw in what I share. Nobody did.
On October 2, unplugandplay filed six issues, #9 through #14, within a minute of each other. Each one points at a file and line and comes with a proposed fix.
Two of them are SQL injections in the PostgreSQL adapter: the geo filter puts the field name into SQL unquoted, and the pgvector code interpolates the embedding into a string literal. Two more are quieter. reindex deletes the index and never creates it again, and the PostgreSQL and Typesense adapters still turn a failed search into an empty result, which I had fixed for Elasticsearch and OpenSearch in June and never carried over.
These are security holes and correctness bugs, and none of them asks for a feature. All six were closed on October 4, two days after they were filed, and the fixes ship in 0.8.0. That’s the review the Reddit thread never got around to.
The race after the landing¶
In For All Mankind, the Soviet landing doesn’t end the race. It starts a longer one, and the show spends its seasons on who can keep a base alive up there.
Noiseless landed in 2021, and nobody pointed a camera at it. That’s fine. Basecamp’s gem will get more Rails developers thinking about search, and some of them will outgrow it and need async I/O with search classes they can scope per role.
If Stefan builds the SQLite adapter, it will be maintained by someone who runs SQLite.
Basecamp can keep the cameras. I have a base to run.
* The Soviets in the title come from For All Mankind. This post is not sponsored by the KGB, and Moscow has never paid me, in rubles or in Ruby.
🔗Interstellar Communications
No transmissions detected yet.Be the first to establish contact!
Related Posts
Matz Told Me Why
In one week, matz closed my pull request to spinel and the /r/rails moderators banned me for good. Matz explained his decision in five sentences. In two years of removing my posts, the moderators never explained one.
The Observability Trap: Why I Built Lapsoss to Break Vendor Chains
How the observability industry's vendor lock-in tactics led to building Lapsoss and the Liberation Stack - community-owned tools that put developers back in control
Parasites With a Term Sheet
Since 2023, companies keep trying to buy the gems I publish, or to hire me under a contract that hands them all of it. I scambaited five of them to the end. The plan never changes: raise $10-20M, then buy someone else's decade of downloads for a few thousand.