Revisiting poro_validator: the code, the argument, and the benchmark

I revisited a Ruby validator I wrote years ago, with AI assistance, containers, and fresh benchmarks. Some ideas held up. The code and the performance argument needed a closer look.

Years ago, I wrote a Ruby gem called poro_validator because I needed a lightweight way to validate incoming data. A request arrives, and I want to check what it contains before deciding what to do with it. Some of that data might eventually become an Active Record model. It didn't need to start as one.

I wanted the rules to live separately, where I could use them for different operations without making the persistence model carry every requirement. Constructing an Active Record object just to check the input felt like extra work.

To get buy-in from my team, I put together some benchmarks and showed them the comparison. I don't remember the exact setup or output anymore, but I do remember that using Active Record was slower than validating the data separately. That was part of the argument for the gem.

Now I'm coming back to both the code and that argument. I still care about validating data at the application boundary. But an idea can hold up while the explanation around it needs work, and the code can have problems that were there long before Ruby changed.

This time, I'm working with AI assistance and my usual containerized development approach. The old benchmarks weren't containerized. These ones would be, along with the tests and builds for the refactoring.

Give the old code its own environment

In my usual workflow, I edit on the host machine and typically do Git operations there too. Most, if not all, execution of the code happens inside containers. I also consider how it will run in CI and production as part of that approach. I'll write more about that another time; here, it meant I could work on old and new Ruby without repeatedly rearranging my machine to accommodate them.

The repository still had some clues about where to begin. The 0.2.4 release goes back to May 2016. Its Travis configuration included Ruby 2.2.3; a later update went up to 2.4.0. That gave me a starting point for running the old gem. It didn't tell me which Rails version was in the application that prompted it.

Containers made it possible to keep those questions separate. The original source could have its own runtime and dependencies. The upgraded gem could run on Ruby 3.4 and 4.0. Neither needed to borrow the Ruby installation I use for everything else.

Even the historical image needed a little help: Docker rejected its old manifest format. I had the image format converted while preserving the Ruby 2.2.3 runtime, and used a modern TLS-capable container to download the old dependencies before installing them offline. Running an old library is one thing. Asking an old runtime to negotiate with today's infrastructure is another.

What I still want from validation

The basic API remains easy to explain:

require 'poro_validator'

class NameInputValidator
  include PoroValidator.validator
  validates :name, presence: true
end

validator = NameInputValidator.new
validator.valid?({ "name" => "Ada" }) # => true
validator.valid?({ "name" => nil })   # => false

The caller supplies a Hash or a Ruby object and decides what happens next. An import, a form and an API request can each have rules suited to the operation, even when they eventually write to the same table. That is the separation I wanted.

The old README took the argument further than it should have. Active Model wasn't confined to Active Record. Rails 4.2 already documented ActiveModel::Validations on an ordinary Ruby class, without a database-backed superclass. Separate plain-object validation was available then too. Rails 4.2 Active Model example.

So wanting a separate validator was a reasonable preference. It wasn't evidence that Rails made the separation impossible.

“Loading an object” also needs unpacking. Requiring a framework, constructing an instance, fetching a row and running validation are different operations. An in-memory Active Record object doesn't automatically mean a database fetch. If I want to make a performance argument now, I need to say which work I'm comparing.

The boundary itself isn't one operation either. Parsing JSON gives me Ruby values. Normalizing keys makes those values easier to address. Coercing a quantity turns accepted text into a number. Validation asks whether the input meets the rules. Then there are decisions that require more than the payload: whether an order can still be cancelled, whether the caller has permission, or whether another system has already acted.

The gem gives those checks a place to live; it doesn't make all of them input checks. It performs no persistence itself, but custom validators, conditions and object readers are ordinary Ruby code. They can perform their own I/O.

A passing check does not reserve a value

Uniqueness is where this distinction becomes particularly practical.

Suppose a handle must be unique within an account. Two requests try to claim it. They might come from two people, two tabs, or overlapping requests from the same person. The database doesn't care that the same person clicked twice.

Here's the race, as an illustrative timeline:

Sequence diagram: Request A and Request B each look up the same account and handle and find no row. A inserts first, then B inserts the same pair. Without a unique constraint or coordination, both inserts succeed and a duplicate exists.

Illustrative interleaving without a unique constraint or a coordination protocol. Time runs from top to bottom; the pair is the account and handle.

Both lookups can give the right answer when they run. Without authoritative enforcement or a suitable coordination protocol, the writes can still produce a duplicate. A preliminary check is useful for feedback, but it hasn't reserved anything. Rails documents this race and recommends a unique database index. Rails uniqueness validation.

I've associated transactions with stronger guarantees, but wrapping these steps in a transaction doesn't settle uniqueness. Atomicity keeps the changes in a transaction together; it doesn't tell two transactions that they aren't allowed to insert the same value. PostgreSQL's default Read Committed isolation allows both absence checks. Serializable isolation can support a properly designed protocol, with serialization failures and retries to handle. PostgreSQL isolation.

An immediately enforced unique index makes the conflict check part of insertion. If another insertion is still uncommitted, PostgreSQL can wait and recheck its outcome. That is a different guarantee from checking first and hoping the answer remains true. PostgreSQL uniqueness checks.

The index also needs to express the rule I actually mean: include the account for scoped uniqueness, agree on normalization and case equivalence, and decide what nulls mean. Ordinary PostgreSQL unique constraints allow multiple nulls; NOT NULL and NULLS NOT DISTINCT address different requirements. PostgreSQL constraints.

The application still owes the user a useful response when the database rejects the write. Rails exposes a uniqueness rejection as ActiveRecord::RecordNotUnique; with PostgreSQL, handling it must respect the transaction boundary rather than carrying on inside a failed transaction. Rails error handling, transaction guidance.

That doesn't suggest adding database integration to PoroValidator. It explains why a successful valid? result and a safe database write are separate things.

Looking at what the code actually did

Some failures came directly from Ruby changing. The old source referenced Fixnum, which Ruby removed in 3.2. Ruby 3.4 changed Hash#inspect, and the gem used inspected Hash text for nested error keys as well as messages. A display change could therefore affect error lookup. Ruby 3.2 notes, Ruby 3.4 notes.

Other problems were already in the original code. Calling everything a Ruby upgrade issue would let those choices off rather conveniently.

Take Hash normalization. The helper lived inside the gem's namespace, but its original declaration included this line:

class ::Hash

That is Ruby's global Hash class. Requiring the gem added its normalization methods to every Hash in the process. A small validation library had quite a reach.

Validation then extended the caller's Hash before reading it. A frozen input could fail with FrozenError before any validation rule ran. The call site now looks like this:

-        entity.extend(::PoroValidator::Utils::DeepSymbolizeKeys)
-        entity = entity.deep_symbolize_keys
+        entity = ::PoroValidator::Utils::DeepSymbolizeKeys.call(entity)

The namespaced function reads the Hash and returns a normalized copy. A frozen payload with a missing name now fails for the useful reason: the name is missing. It behaves like the mutable version, and requiring the gem no longer adds those methods to Hash. Full diff, frozen-input and global-method tests.

There are still limits to the normalization: conflicting String and Symbol keys keep the later value, and Hashes inside Arrays aren't recursively converted. Removing the global patch didn't require inventing a new policy for either.

Two instances, one shared rule

Another problem was less visible at the call site. Rule objects were retained by the validator class, so different validator instances used the same rules. Those rules also kept the context and errors from their most recent call.

In the probe, call A paused while reading a missing name. Call B ran through another validator instance with a present name. When A resumed, its rule was holding B's context.

Call Before the repair After the repair
A, missing name Returned true without its error Returned false with its presence error
B, present name Returned true, then acquired A's error Returned true and kept its errors empty

The instances were separate. Their state wasn't separate all the way down.

The accessor portion of the change shows where the active call now takes precedence:

       def errors
-        @errors
+        (current = __current_context__) ? current.errors : @errors
       end

       def context
-        @context
+        __current_context__ || @context
       end

The rest of the repair keeps a stack of active contexts for each rule in the executing fiber, with an ensure block to unwind it. Nested synchronous calls need this too; the problem wasn't limited to threads. The fix protects these active calls, not arbitrary custom state or concurrent use of one shared outer validator instance. Complete diff, overlapping-call tests.

Passing an error along shouldn't take it away

Composition had a more direct mistake. A child validator could report two failures for a field, but the parent took only the final message. It also removed that message from the child.

The original transfer loop contained:

if k.to_s == attribute.to_s
  errors.add(attribute, v.pop)
else
  errors.add([attribute, k.to_sym], v.pop)
end

pop was doing exactly what Ruby promises. It was my use of it that needed attention. Both branches of the repaired transfer now iterate over the messages:

v.each { |message| errors.add(key, message) }

Keeping the messages was only part of the work. A nested child path could be turned into serialized text and treated as one literal attribute name by its parent. The repaired store records the actual path names, so [:address, :country, :code] can survive multiple composition boundaries.

The full change includes the wrapper handling and fallback for older or unrecorded keys. Direct edits to the exposed store Hash remain outside that path tracking. That was a deliberate limit on the repair. Composition diff, message tests, nested-path tests.

Numeric comparison needed more than a quick conversion too. A String could be recognized as numeric and still fail when compared with an Integer. Converting decimal text to Float could lose enough precision to accept an out-of-bounds value. The repair parses decimal text exactly and compares a Float using its stored value. That means decimal "0.1" and Float 0.1 aren't exactly equal under the chosen policy. Comparison implementation.

These are the examples I wanted in the article when an earlier draft told me the full suite had passed. Which examples? What had actually changed? A missing error or a frozen payload failing before validation tells me more than a total.

Reviewing the work, including the AI

I used Codex to inspect the code, challenge my reasoning, prepare bounded handoffs and review the returned work, and Claude Code to implement the repairs discussed here. I decided the scope and whether to accept the result.

That division didn't make the work automatically correct.

One renderer repair introduced a recursion failure: an inclusion array containing itself made error reporting end in SystemStackError. The original release could render it. This was a new problem from the repair, and the follow-up had to distinguish a cycle from the same value appearing in separate branches.

Coverage supplied another useful embarrassment. The filter excluding specs matched the substring spec, which also appears in stable_inspect.rb. The renderer ran in the tests but disappeared from the coverage calculation. Codex's earlier review reproduced the reported 100 percent without noticing the exclusion.

After correcting the filter, the percentage stayed the same. What it counted changed.

The assertions needed scrutiny as well. Comparing message text could miss that the message object had been copied. Comparing package filenames could miss changed library bytes. A stale gem could make an installation procedure look successful after its build had failed. Faulty disposable copies helped check whether a test would actually catch the behavior it claimed to protect.

Eventually I asked why packaging kept coming back with another handoff. Two assertions were fixed, and then there seemed to be two assertions again. Was I close to finishing this or what?

The corrections had reasons, but so did the question. I needed an endpoint for the upgrade. A consolidated audit separated the remaining defects from optional cleanup and future release decisions. The final package was built and installed in plain-Ruby containers without the development checkout: valid input passed, missing input failed, and the loaded code came from the installed gem.

The upgraded source is now on public main, and the hosted CI run passed its Ruby 3.4 and 4.0 jobs. That gave me a working version to compare with the original, rather than another reason to postpone the comparison.

Running the comparison again

For the rules and inputs I tested, modern Active Model was faster at returning a validation result. Asking it for the error messages changed the comparison.

That needs explaining, especially in an article that starts with my memory of a faster alternative to Active Record. These benchmarks use ActiveModel::Validations on ordinary Ruby objects. They aren't a rerun of the old Active Record experiment.

I compared the original gem on Ruby 2.2.3 with Active Model 4.2.6, and the upgraded gem on Ruby 3.4.11 and 4.0.7 with Active Model 8.1.4. The gem sources came from the immutable original release and the reviewed upgraded commit. Neither was patched for the benchmark.

The rules were intentionally ordinary: a required name, a name length from two to 32, and a three-letter uppercase code. Ada/ABC passes. An empty name gives presence and length errors; a lowercase code gives a format error. Both adapters skip length and format on nil. I checked validity, failing attributes and error counts before timing.

They don't have identical semantics everywhere. Poro treats false as present; Active Model doesn't. That case was a separate control, excluded from timing. A benchmark shouldn't quietly compare two different answers.

Loading isn't the whole cost

What did I actually time? For loading, this was the bracket in the benchmark worker. The runner supplied the library, source directory and expected version, then started a fresh Ruby process for each sample:

def clock
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
end

load_start = clock
if library == 'poro'
  $LOAD_PATH.unshift(File.join(source, 'lib'))
  require 'poro_validator'
  raise 'unexpected loaded Poro source' unless $LOADED_FEATURES.include?(File.join(source, 'lib/poro_validator.rb'))
elsif library == 'active_model'
  require 'active_model'
  raise 'Active Model version mismatch' unless ActiveModel::VERSION::STRING == ENV.fetch('EXPECTED_AM')
else
  raise 'unknown library'
end
load_elapsed = clock - load_start

That includes Poro's load-path insertion and the small identity/version checks shown here. JSON and digest support had already loaded before this bracket; the validation classes were declared afterward. I took 20 fresh-process samples per library in each environment.

Poro took less time to load in each environment:

Ruby / Active Model Poro require Active Model require
2.2.3 / 4.2.6 4.19 ms 40.64 ms
3.4.11 / 8.1.4 4.61 ms 33.15 ms
4.0.7 / 8.1.4 6.05 ms 33.61 ms

These are median times around require in fresh Ruby processes, excluding container startup and class declaration. They aren't measurements of cold disk loading. In an existing Rails process, the framework is already there; I don't get to count that loading difference as a saving on every request.

For repeated valid? calls with the combined rules, the results looked like this. This time, loading, rule declaration and object construction were already done. The operation was:

validate = lambda do
  library == 'poro' ? validator.valid?(entity) : validator.valid?
end

Poro received the prepared input object; Active Model held those values on its prepared validator object. I warmed that operation with 2,000 calls, then timed a calibrated batch of repeated calls. This excerpt leaves out the memory and GC counter collection around the loop:

warmup.times { operation.call }
GC.start

start = clock
iterations.times { operation.call }
elapsed = clock - start

For these rows, operation was that validate lambda. The tables report median microseconds per call across ten worker processes per library; lower is faster.

With valid input:

Ruby Poro Active Model
2.2.3 6.27 5.79
3.4.11 11.14 6.06
4.0.7 9.65 5.52

With an empty name:

Ruby Poro Active Model
2.2.3 9.49 227.80
3.4.11 20.40 7.84
4.0.7 17.40 6.58

The historical valid-input case was close; rejected input was much more expensive in Active Model 4.2.6. Modern Active Model was faster for all the selected valid and invalid valid? cases.

Including construction and handling a String-keyed Hash didn't reverse the modern result either. On Ruby 3.4, that valid-input operation took 16.17 microseconds in Poro and 7.19 in Active Model. The payloads were prepared and reused, so this describes warmed boundary use.

Then ask it to explain the failure

A boolean result isn't always where validation ends. Often the next question is what to tell the person who submitted the input.

For the empty-name case, running validation and then requesting errors.full_messages produced these results:

Ruby Poro Active Model
3.4.11 21.20 µs 225.55 µs
4.0.7 18.59 µs 209.09 µs

The libraries do some of their work at different times. Active Model 4.2.6 generates message text when adding an error. In 8.1.4, adding an error normally stores an Error object; its message text is generated when requested, and full_messages also formats the attribute name. Poro creates its message strings during validation. Historical error addition, modern error addition, modern message generation.

So modern Active Model can return false before doing text work that appears in the later measurement. Its structured errors and localization support also differ from Poro's simpler string reporting. I haven't profiled every part of the difference, and matching validity and error count doesn't make the human output identical.

That is why I want the operations named beside the numbers. A boundary check that only needs a boolean and a form that needs useful error messages are asking for different amounts of work.

There are limits to the experiment too. All runs used AMD64 containers under translation on an ARM64 OrbStack engine, including the historical environment. This is old software on today's machine, not recovered original hardware. The Ruby, Active Model, dependencies and base operating system change between the historical and modern rows, so those rows cannot isolate the performance effect of a particular repair.

The tables show selected results; the benchmark notes explain how I collected them. I also retained the raw samples and additional allocation and memory measurements. The benchmark needed a correction of its own: the first construction adapter put Poro's two objects in an extra Array. I kept those samples and repeated construction without that benchmark-owned allocation.

My memory of the Active Record comparison still belongs to the original discussion. These measurements give me a new comparison with Active Model. They don't establish what happened in the benchmark I showed the team.

What I can keep, and what needs a better explanation

The separation I wanted is still practical. I can check incoming data without first making it a persistence model, and keep different rules for different operations. Active Model can support that separation too; the old README needed to acknowledge it.

The original code had useful simplicity at its public boundary, with less obvious complexity underneath: shared rule state, global Hash methods and error keys built from display text. Making the gem work on current Ruby meant looking at those choices as well as the compatibility failures.

The current poro_validator code and tests are on GitHub if you want to look through what changed.

The measurements give the word “lightweight” something more specific to mean. Poro loaded faster in these runs. Modern Active Model returned validation results faster for the selected cases. Requesting their different error messages changed the comparison again. A smaller dependency footprint doesn't answer all three questions.

I can keep the boundary idea without making the gem prove every argument I once attached to it. The next time I show someone a benchmark, I can point to the operation being measured, the code that ran, and the parts I still need to explain.

Benchmark notes

The comparison used the original release and upgraded source. Historical dependencies were selected from releases available at the time; they weren't recovered from my application's lockfile.

Each warmed workload used ten independent worker processes per library, after 2,000 warm-up calls. Both libraries used a common calibrated batch size, with garbage collection enabled and JIT disabled. Loading used 20 fresh processes per library. I balanced and shuffled execution order and ran performance workers sequentially. Container startup was outside the measured time.

The tables report medians, without baseline subtraction or outlier removal. For example, Ruby 3.4's valid combined-rule workers ranged from 10.78 to 12.34 microseconds for Poro and 5.72 to 6.50 for Active Model. The desktop and shared VM weren't controlled laboratory conditions, and translation can affect code paths differently. These results describe this setup, not a universal ranking.