<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Databases on Brian Faust</title><link>https://wimdy.works/tags/databases/</link><description>Recent content in Databases on Brian Faust</description><generator>Hugo</generator><language>en-US</language><lastBuildDate>Mon, 01 Jan 0001 00:00:00 +0000</lastBuildDate><atom:link href="https://wimdy.works/tags/databases/rss.xml" rel="self" type="application/rss+xml"/><item><title>Replication Copies Corruption Too</title><link>https://wimdy.works/thoughts/replication-copies-corruption-too/</link><pubDate>Tue, 01 Sep 2026 00:00:00 +0000</pubDate><guid>https://wimdy.works/thoughts/replication-copies-corruption-too/</guid><description>&lt;p&gt;The primary answers &lt;code&gt;SELECT 1&lt;/code&gt;. So does its streaming standby. Their WAL
positions show that the standby has caught up, and PostgreSQL promotes it
without error. &lt;code&gt;shipment-100&lt;/code&gt; is missing from both.&lt;/p&gt;
&lt;p&gt;Every infrastructure signal in that sequence can be true at the same time. The
&lt;a href="https://wimdy.works/reproductions/ops-04/"&gt;OPS-04 fixture&lt;/a&gt;
 makes it happen with PostgreSQL 18.1: a valid
&lt;code&gt;DELETE&lt;/code&gt; commits on the primary, reaches the physical standby, and survives
promotion. Failover restores a writable endpoint, but the deleted shipment is
still deleted. Replication worked perfectly. That is the problem.&lt;/p&gt;</description></item><item><title>A Valid Pricing Row Can Still Be Wrong</title><link>https://wimdy.works/thoughts/a-valid-pricing-row-can-still-be-wrong/</link><pubDate>Sun, 15 Feb 2026 00:00:00 +0000</pubDate><guid>https://wimdy.works/thoughts/a-valid-pricing-row-can-still-be-wrong/</guid><description>&lt;p&gt;One row charges EUR 12 from zero through five kilograms. Another charges
EUR 18 from four through ten. Give them different names and sequence numbers,
attach them to the right tariff book, and both rows can be perfectly valid
database records. At 4.5 kilograms, they still disagree about the base price.
Adding them gives EUR 30, which nobody specified as the base rate.&lt;/p&gt;
&lt;p&gt;Moving my private rating engine from
&lt;a href="https://wimdy.works/thoughts/not-every-pricing-rule-belongs-in-a-formula/"&gt;expression definitions to explicit rows&lt;/a&gt;

made this problem easier to inspect. It didn&amp;rsquo;t solve it by itself. I needed
to decide which combinations should be impossible, which should remain valid
drafts, and which should stop a ruleset from becoming executable.&lt;/p&gt;</description></item><item><title>Not Every Pricing Rule Belongs in a Formula</title><link>https://wimdy.works/thoughts/not-every-pricing-rule-belongs-in-a-formula/</link><pubDate>Sun, 01 Feb 2026 00:00:00 +0000</pubDate><guid>https://wimdy.works/thoughts/not-every-pricing-rule-belongs-in-a-formula/</guid><description>&lt;p&gt;The first parcel costs EUR 20. Parcels two through ten cost EUR 5 each.
After that, each additional parcel costs EUR 3. Twelve parcels should cost
&lt;code&gt;20 + 9 × 5 + 2 × 3 = 71&lt;/code&gt; euros. There is nothing particularly exotic about
that tariff. Turning it into three small programs still creates more work
than the arithmetic suggests.&lt;/p&gt;
&lt;p&gt;My first pricing model could express those programs. It knew about package
scope, conditions, calculated amounts, and ordered adjustments. The problem
wasn&amp;rsquo;t an inability to calculate the right price. It was that ordinary tariff
structure kept arriving as another expression for the system to interpret.
That affected editing, validation, and eventually execution.&lt;/p&gt;</description></item><item><title>Eager Loading Is Not a Query Shape</title><link>https://wimdy.works/thoughts/eager-loading-is-not-a-query-shape/</link><pubDate>Sat, 01 Feb 2025 00:00:00 +0000</pubDate><guid>https://wimdy.works/thoughts/eager-loading-is-not-a-query-shape/</guid><description>&lt;p&gt;A catalogue endpoint returns 25 names and the number of active variants behind
each one. The first implementation runs 26 queries. Adding
&lt;code&gt;with('activeVariants')&lt;/code&gt; reduces that to two.&lt;/p&gt;
&lt;p&gt;The N+1 defect is gone. The new implementation still hydrates 1,250 &lt;code&gt;Variant&lt;/code&gt;
models, including a synthetic 4 KiB metadata column, to produce 25 integers.&lt;/p&gt;
&lt;p&gt;Eager loading solved the repeated-query shape. It did not ask what the response
needed. That is the larger decision: queries, selected rows and columns,
hydrated models, serialization, and the database plan all belong to one read
path.&lt;/p&gt;</description></item><item><title>CQRS Starts When Reads Need a Different Model</title><link>https://wimdy.works/thoughts/command-query-separation-vs-cqrs/</link><pubDate>Mon, 01 May 2023 00:00:00 +0000</pubDate><guid>https://wimdy.works/thoughts/command-query-separation-vs-cqrs/</guid><description>&lt;p&gt;An order-details page needs customer name, payment status, shipment progress,
line totals, tax labels, and the latest support case. The write model needs to
protect payment, cancellation, and fulfilment invariants.&lt;/p&gt;
&lt;p&gt;Trying to make one Eloquent graph serve both jobs creates a familiar mess:
relationships exist for screen composition, write methods expose persistence
concerns, and every request loads a different subset of a model that is never
quite complete.&lt;/p&gt;
&lt;p&gt;This is where CQRS becomes a real option. It still does not automatically
justify two databases, an event store, a command bus, or eventual consistency.
CQRS is the model split; the infrastructure is a separate set of decisions.&lt;/p&gt;</description></item><item><title>A Version Column Is Not Concurrency Control</title><link>https://wimdy.works/thoughts/a-version-column-is-not-concurrency-control/</link><pubDate>Sun, 01 Jan 2023 00:00:00 +0000</pubDate><guid>https://wimdy.works/thoughts/a-version-column-is-not-concurrency-control/</guid><description>&lt;p&gt;A capacity pool has ten units available. Two workers load it before either one
writes:&lt;/p&gt;
&lt;figure class="code-figure"&gt;
 &lt;div class="code-block" data-copy-code&gt;
 &lt;button class="copy-code" type="button" aria-label="Copy code"&gt;copy&lt;/button&gt;&lt;span class="sr-only" data-copy-status role="status" aria-live="polite"&gt;&lt;/span&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;worker A loads: version 0, reserved 0
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;worker B loads: version 0, reserved 0
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;worker A allocates 6 to allocation-a
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;worker B allocates 6 to allocation-b&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
 &lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;Both decisions pass the aggregate&amp;rsquo;s rule. Each worker sees ten remaining
units. They cannot both belong to one ordered history: after either allocation,
only four remain.&lt;/p&gt;</description></item><item><title>Construction Should Not Hide Durable Writes</title><link>https://wimdy.works/thoughts/construction-should-not-hide-durable-writes/</link><pubDate>Tue, 01 Sep 2020 00:00:00 +0000</pubDate><guid>https://wimdy.works/thoughts/construction-should-not-hide-durable-writes/</guid><description>&lt;p&gt;A test called a model factory&amp;rsquo;s &lt;code&gt;make()&lt;/code&gt; method. The model itself was never
saved. Two related records were.&lt;/p&gt;
&lt;p&gt;The factory computed the model&amp;rsquo;s default address and account fields by creating
real database rows. A test which only needed an in-memory user therefore changed
shared state before it reached its first assertion.&lt;/p&gt;
&lt;p&gt;A surrounding test transaction usually hid the damage by rolling it back.
Tests without that transaction left the rows behind, while another test used
&lt;code&gt;TRUNCATE&lt;/code&gt; for cleanup, committed the MySQL transaction, and made earlier writes
survive the rollback. The method returned an unpersisted object, but its name
still gave the wrong account of what had happened.&lt;/p&gt;</description></item><item><title>An Index Must Serve a Real Query</title><link>https://wimdy.works/thoughts/an-index-must-serve-a-real-query/</link><pubDate>Sun, 01 Oct 2017 00:00:00 +0000</pubDate><guid>https://wimdy.works/thoughts/an-index-must-serve-a-real-query/</guid><description>&lt;p&gt;A production query needed a small page of recent records requiring operational
attention. The table already had an index which looked relevant. The measured
scan still discarded 84,003 historical index entries and recorded 122,936
shared-buffer reads before finding 83 eligible rows.&lt;/p&gt;
&lt;p&gt;The table did not need another vaguely relevant index. It needed an index for
this query:&lt;/p&gt;
&lt;figure class="code-figure"&gt;
 &lt;div class="code-block" data-copy-code&gt;
 &lt;button class="copy-code" type="button" aria-label="Copy code"&gt;copy&lt;/button&gt;&lt;span class="sr-only" data-copy-status role="status" aria-live="polite"&gt;&lt;/span&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;one tenant
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;active records with zero price
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;created during the last three months
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;newest creation time first
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;stable identifier as the tie-breaker
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;first page only&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
 &lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;That is the actual starting point: the access path, not a list of columns which
sound important in isolation.&lt;/p&gt;</description></item><item><title>Pagination Needs a Stable Order</title><link>https://wimdy.works/thoughts/pagination-needs-a-stable-order/</link><pubDate>Fri, 01 Sep 2017 00:00:00 +0000</pubDate><guid>https://wimdy.works/thoughts/pagination-needs-a-stable-order/</guid><description>&lt;p&gt;A financial report encoded two sort rules as one string:&lt;/p&gt;
&lt;figure class="code-figure"&gt;
 &lt;div class="code-block" data-copy-code&gt;
 &lt;button class="copy-code" type="button" aria-label="Copy code"&gt;copy&lt;/button&gt;&lt;span class="sr-only" data-copy-status role="status" aria-live="polite"&gt;&lt;/span&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;lowercased customer name + &amp;#34;-&amp;#34; + customer identifier&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
 &lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;Sharded tests with concurrently created identifiers exposed unreliable output.
The correction stopped asking string concatenation to double as a sorting
contract. It compared names directly and consulted the stable customer
identifier only when the names matched.&lt;/p&gt;
&lt;p&gt;The diff does not prove that equal names alone caused the failing
order. It does expose the rule every paginator depends on: the primary
comparison, tie-breaker, and comparison semantics must be explicit, and each
row needs exactly one reproducible position.&lt;/p&gt;</description></item><item><title>Time Zones Are Part of the Data Model</title><link>https://wimdy.works/thoughts/time-zones-are-part-of-the-data-model/</link><pubDate>Thu, 01 Jun 2017 00:00:00 +0000</pubDate><guid>https://wimdy.works/thoughts/time-zones-are-part-of-the-data-model/</guid><description>&lt;p&gt;One metering path assigns an event to a billing month after converting its
occurrence time into the account&amp;rsquo;s timezone. The same path derives its daily
usage key directly from the occurrence timestamp without that conversion.&lt;/p&gt;
&lt;p&gt;Near midnight, those two fields can disagree:&lt;/p&gt;
&lt;figure class="code-figure"&gt;
 &lt;div class="code-block" data-copy-code&gt;
 &lt;button class="copy-code" type="button" aria-label="Copy code"&gt;copy&lt;/button&gt;&lt;span class="sr-only" data-copy-status role="status" aria-live="polite"&gt;&lt;/span&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;event instant: May 31, 21:30 UTC
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;local business time: June 1, 00:30
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;billing period: June
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;stored usage date: May 31&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
 &lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;Each expression looks like an ordinary date calculation. They model different
things, and the defect stays invisible until the difference crosses a day or
month boundary.&lt;/p&gt;</description></item><item><title>Money Is Not a Floating-Point Number</title><link>https://wimdy.works/thoughts/money-is-not-a-floating-point-number/</link><pubDate>Mon, 01 May 2017 00:00:00 +0000</pubDate><guid>https://wimdy.works/thoughts/money-is-not-a-floating-point-number/</guid><description>&lt;p&gt;A booking-time price arrived as four values:&lt;/p&gt;
&lt;figure class="code-figure"&gt;
 &lt;div class="code-block" data-copy-code&gt;
 &lt;button class="copy-code" type="button" aria-label="Copy code"&gt;copy&lt;/button&gt;&lt;span class="sr-only" data-copy-status role="status" aria-live="polite"&gt;&lt;/span&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;net amount: 407
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;tax rate: 25.5%
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;tax amount: 104
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;gross amount: 511&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
 &lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;The amounts are integers in the currency&amp;rsquo;s minor unit. The rate is a decimal
ratio. The contract checks two things: net plus tax equals gross, and the
declared rounding rule produces the supplied tax amount from net and rate.&lt;/p&gt;
&lt;p&gt;None of those details is bookkeeping trivia. Remove the unit, mix the rate&amp;rsquo;s
two representations, or let two services round at different stages, and the
same price can stop balancing while every individual calculation still looks
reasonable.&lt;/p&gt;</description></item><item><title>Null Is a Domain Decision</title><link>https://wimdy.works/thoughts/null-is-a-domain-decision/</link><pubDate>Sat, 01 Apr 2017 00:00:00 +0000</pubDate><guid>https://wimdy.works/thoughts/null-is-a-domain-decision/</guid><description>&lt;p&gt;We added four nullable fields to preserve a pricing snapshot: net amount, tax
rate, tax amount, and gross amount. Each field could now be absent. That did
not make each field independently optional.&lt;/p&gt;
&lt;p&gt;For older records, all four nulls meant one thing: this record predates the
snapshot and does not contain captured pricing evidence. For new records, all
four values had to arrive together. A row containing gross and net but no tax
rate was not “partly known.” It was invalid.&lt;/p&gt;</description></item><item><title>A Migration Is a Production Change</title><link>https://wimdy.works/thoughts/a-migration-is-a-production-change/</link><pubDate>Wed, 01 Mar 2017 00:00:00 +0000</pubDate><guid>https://wimdy.works/thoughts/a-migration-is-a-production-change/</guid><description>&lt;p&gt;We deployed an additive schema change which the database could execute without
rewriting the table. On paper it was cheap. In production it waited
indefinitely.&lt;/p&gt;
&lt;p&gt;The statement needed an exclusive table lock. A long-running session already
held a conflicting lock, so the migration queued. New application traffic then
queued behind the migration. The column addition was cheap after lock
acquisition; waiting to begin was the incident.&lt;/p&gt;
&lt;p&gt;Another schema change looked cheap for the opposite reason: remove fields and
tables which the application no longer read. The SQL was small, but the
decision would erase financial history from data sets measured in tens of
gigabytes. Neither change could be judged by reading the migration method
alone.&lt;/p&gt;</description></item><item><title>A Transaction Defines What Must Succeed Together</title><link>https://wimdy.works/thoughts/a-transaction-defines-what-must-succeed-together/</link><pubDate>Wed, 01 Feb 2017 00:00:00 +0000</pubDate><guid>https://wimdy.works/thoughts/a-transaction-defines-what-must-succeed-together/</guid><description>&lt;p&gt;A booking flow reserved funds, completed the remote booking, and captured the
funds. Only then did synchronous response processing fail. The remote booking
existed, the funds were captured, and the caller received a failure.&lt;/p&gt;
&lt;p&gt;The capture was individually valid, and its own database transaction committed
cleanly. It was still too early. We had drawn the success boundary around
“remote booking returned” while the operation promised something larger: a
complete booking result which the caller could use.&lt;/p&gt;</description></item><item><title>Let the Database Reject Impossible State</title><link>https://wimdy.works/thoughts/let-the-database-reject-impossible-state/</link><pubDate>Sun, 01 Jan 2017 00:00:00 +0000</pubDate><guid>https://wimdy.works/thoughts/let-the-database-reject-impossible-state/</guid><description>&lt;p&gt;Two workers asked the same reasonable question: does this owner already have an
active record? Both received the same honest answer: no. Each worker then
created one.&lt;/p&gt;
&lt;p&gt;Nothing in either request was obviously careless. The application checked
first. The insert happened inside a transaction. Tests which ran one request at
a time stayed green. The defect lived in the interval between the check and the
write, where both transactions were allowed to be correct about a state which
had already become stale.&lt;/p&gt;</description></item><item><title>A Release Gate Must Fail Before Incompatible Code Goes Live</title><link>https://wimdy.works/thoughts/a-release-gate-must-fail-before-incompatible-code-goes-live/</link><pubDate>Tue, 01 Nov 2016 00:00:00 +0000</pubDate><guid>https://wimdy.works/thoughts/a-release-gate-must-fail-before-incompatible-code-goes-live/</guid><description>&lt;p&gt;We had a deployment step everybody knew about and people still forgot it. The
code was pulled manually, then someone had to run a shell script which applied
pending migrations. The instructions existed, but the habit remained
concentrated in one operator.&lt;/p&gt;
&lt;p&gt;Eventually a deployment completed without the script. Traffic reached code
which expected columns or tables that did not exist, and database queries began
failing. Running the migrations restored the application. There was no obscure
database bug here; the release process had declared success before one of its
prerequisites was true.&lt;/p&gt;</description></item><item><title>Production Bugs Can Live in the Data, Not the Code</title><link>https://wimdy.works/thoughts/production-bugs-can-live-in-the-data-not-the-code/</link><pubDate>Mon, 01 Feb 2016 00:00:00 +0000</pubDate><guid>https://wimdy.works/thoughts/production-bugs-can-live-in-the-data-not-the-code/</guid><description>&lt;p&gt;A delivery integration returned a label, but the application could not save
it. The same persistence path had passed with the fixtures used elsewhere.&lt;/p&gt;
&lt;p&gt;The failed label was not malformed. It was simply larger than the database
column could hold after Base64 encoding.&lt;/p&gt;
&lt;p&gt;That distinction changes the investigation. Comparing deployed revisions
would find nothing because the revision was not the variable. Repeating a
familiar fixture would also find nothing because the fixture did not cross the
same storage boundary. The investigation had treated “same code” as “same
experiment” while leaving the input that decided the result uncontrolled.&lt;/p&gt;</description></item></channel></rss>