Transactions and consistency

Does obfuscation break transaction isolation options?

A database client is configured by narrowing its defaults: a stricter isolation level than the engine ships with, a read-only connection for reporting, a bound on how long a statement may run or a lock may be waited for, and a rule about which failures may be repeated. All of those are property names on an options object, and the client reading them was installed rather than built. We protected a file that configures one, renamed the names a group at a time, and counted the money.

What the sample actually does

The file opens two transactions against one account holding 100, has both of them read the balance before either commits, and has both withdraw 60. That is the oldest race in the business and it is the reason the isolation level exists. It then runs four more things whose only purpose is to make every other guard fire exactly once: a reporting connection that tries to stamp a column on the way out, an update against a row a nightly job is holding, an analytics query that declares itself to take thirty seconds, and a payout insert that the server applies and then times out on.

The client is copied into the measurement directory unprotected, because that is the shape of the real thing. Your bundle is rebuilt when you protect it; the package in node_modules is not, and it keeps reading the property names it has always read. Its defaults are the permissive ones this family of libraries ships: read committed, read-write, no lock timeout, no statement timeout, no retries, and a retry classifier that says yes to everything.

Protection alone was applied first, on five presets covering both output targets, the gate profile and the compressed profile. All five behaved identically to the unprotected file. Nothing below is caused by protection on its own. Every result required member renaming aimed at the names, which is what the MemberRegexp option exists to scope.

The sharpest result was not an option name at all

The arm that did the most damage in this area renamed three ordinary column names - the identifier, the owner and the balance - and no option at all. It is the arm you would predict to be inert, because the application appears to own both sides: it writes the patch and it reads the row.

It does not own both sides. The row belongs to the database. When the patch object's key was renamed, the update was still accepted, still reported one row affected, and still committed - into a column that had never existed until that moment. The stored columns went from the three the table has to those three plus a new one. The old column kept its old value.

The consequence is printed on one line of the run. Two withdrawals of 60 succeeded, and the balance was still 100. The money was taken and the account was never debited. There is no error anywhere: every statement did what it was asked, and what it was asked changed by one character.

It gets worse in a way worth stating on its own. The conflict detection that makes the strict isolation level useful is defined over the columns in the write set. Once the write set names a column nobody else touches, there is nothing to conflict about, so the second commit sailed through. The guard and the thing the guard protects were switched off by the same edit. The application's own summary line for this, which reads false in every other arm in this article, read true here: overdrawn without noticing.

The isolation level, when you lose it the ordinary way

Renaming the isolation option itself is the same race with a more familiar shape. The reported level went from serializable to read committed - the engine default on the two databases most of this audience runs - and the second commit succeeded rather than being refused.

Two withdrawals of 60 were applied against a balance of 100 and the balance ended at 40. Sixty units of money left the building without a corresponding debit. Nothing raised, nothing retried, and the statement log looks exactly like a successful pair of transactions, because that is what it is.

The difference between this arm and the one above is what the ledger says afterwards. Here the balance moved and the arithmetic is wrong. There the balance did not move at all. The first is findable by reconciliation; the second passes reconciliation on the account and fails only against the payout ledger, which is usually a different system owned by a different team.

The retry classifier is the quiet one, and it doubles a payment

The application supplies its own classifier: a serialization failure may be repeated, a statement timeout on a write may not, because the server may have applied that write before the timeout fired and nothing on this side can tell.

Renaming that one name replaced it with the client's builtin, which retries everything. The payout insert - which the server had applied - was repeated until the retry budget ran out. The payout table went from one row to three, all carrying the same reference. The reported policy moved from a caller-supplied classifier to the library's own.

Notice which half of the pair is dangerous. Renaming the retry COUNT instead is loud: the serialization failure stops being retried and surfaces to the user as an error the first time anybody races, which somebody reports that afternoon. Renaming the CLASSIFIER is silent and pays somebody three times. That asymmetry has now held for eight consecutive passes in this series: the option that decides how hard to try fails loudly, and the option that decides what may be tried fails quietly.

Everything else that came off, briefly

The read-only reporting connection stopped being read-only, and the stamp an ORM hook writes on the way out committed to the primary. The column list of the account table grew a column that a reporting path is not supposed to be able to add. This is the arm most likely to be caught, because eventually it collides with a migration.

The lock timeout went from two seconds to unbounded, and the blocked update waited on the engine's deadlock detector instead - a minute, on a connection out of a pool that is not large. One row held by one slow job now decides how many requests your application can serve.

The statement cap went from five seconds to uncapped and the thirty-second analytics query ran to completion. That is a capacity problem rather than a correctness one, and it is the arm that shows up in a dashboard rather than in a report.

One option was pinned to a value identical to the library default on purpose, as a control. It behaved exactly as predicted: renaming it changed nothing at all, for the ninth consecutive pass in which that prediction has held.

And the arm that renames the result fields the client hands back - the committed flag, the reason, the attempt count - fails in the opposite direction. Every one of them read undefined, the application concluded no withdrawal had been applied, and the run reported zero of two. Names flowing into a dependency fail silently; names flowing out of one fail loudly. That has been the pattern in every area this series has measured.

What to do about it

The mechanism is not specific to databases and it is not a defect in the obfuscator. Member renaming rewrites property names inside the code it is given. An installed client is not inside that code, so a renamed option name is a name it has never heard of, and every mature library ignores what it does not recognise and applies its documented default. A renamed COLUMN name is worse than ignored: it is accepted, because a column name is data to the client and a new one is a perfectly ordinary thing for a caller to send.

The practical control is scoping. RenameMembers takes a MemberRegexp, and two things belong outside it: the options object handed to the database client, and the shape of any row or patch object that crosses into it. If you would rather not maintain an exclusion list, build both with quoted string keys and read them with bracket access using literal strings you wrote.

The verification that catches the whole area is not a test, it is an assertion at start-up plus one at write time. Ask the connection what isolation level it is running at and fail the boot if it is not the one you configured. Then have the write path assert that the columns it is about to send are columns the table has - a check that fails closed, because an allowlist of column names stops matching when a name moves, while a denylist of forbidden ones keeps passing.

A functional test signs in, withdraws money and sees the right balance on every arm in this article except two. What catches the rest is a reconciliation that compares the ledger against the account, run as a test rather than as a monthly report.

Frequently asked questions

Does protecting my JavaScript break database transactions on its own?

Not in this measurement. The sample was protected on five profiles covering both output targets, the gate profile and the compressed profile, and all five behaved identically to the unprotected file. Every result in this article required member renaming pointed at property names.

Which arm was the worst?

The one that renamed column names rather than option names. The debit landed in a brand new column, the old column kept its old value, and two withdrawals of 60 succeeded against a balance that stayed at 100.

Why did the strict isolation level not stop that?

Because conflict detection is defined over the columns in the write set. Once the write set named a column nothing else touches, there was no conflict to detect, so the second commit was accepted. The guard and the thing it guards were lost in one edit.

Is a renamed column an error at the database?

Not from the client's point of view. A column name is data being sent, not code being called, so a name the caller has never used before is an ordinary request rather than a mistake. Whether it is rejected depends entirely on the schema strictness you configured, and most application paths do not configure any.

Which is more dangerous, losing the retry count or losing the retry classifier?

The classifier, by a long way. Losing the count makes a transient failure surface as a user-visible error the first time anybody races, which gets reported. Losing the classifier repeats a write whose outcome is unknown, and in this measurement it produced three payout rows carrying the same reference.

Would our tests have caught this?

A test that withdraws money and checks the resulting balance passes on the sharpest arm here, because the balance is the number that did not change. What catches it is a reconciliation between the ledger and the account, plus a start-up assertion on the isolation level the connection actually reports.

What is the recommended fix?

Scope member renaming so that the database options object and the shape of row and patch objects are excluded, or write both with quoted string keys and literal bracket reads. Then assert the reported isolation level at boot and validate outgoing column names against an allowlist, which fails closed when a name moves.

Related reading