We have pgvector at home
This is an external post of mine. Click here if you are not redirected.
This is an external post of mine. Click here if you are not redirected.
In databases designed for high availability and scalability, secondary nodes can fall behind the primary. Typically, a quorum of nodes is updated synchronously to guarantee durability while maintaining availability, while remaining standby instances are eventually consistent to handle partial failures. To balance availability with performance, synchronous replicas acknowledge a write only when it is durable and recoverable, even if it is not yet readable.
As a result, if your application writes data and then immediately queries another node, it may still see stale data.
Here’s a common anomaly: you commit an order on the primary and then try to retrieve it from a reporting system. The order is missing because the read replica has not yet applied the write.
PostgreSQL and MongoDB tackle this problem in different ways:
WAIT FOR LSN command, allowing applications to explicitly coordinate reads after writes.afterClusterTime read concern.Both approaches track when your write occurred and ensure subsequent reads observe at least that point. Let’s look at how each database does this.
WAIT FOR LSN (PG19)
PostgreSQL records every change in the Write‑Ahead Log (WAL). Each WAL record has a Log Sequence Number (LSN): a 64‑bit position, typically displayed as two hexadecimal halves such as 0/40002A0 (high/low 32 bits).
Streaming replication ships WAL records from the primary to standbys, which then:
The write position determines what can be recovered after a database crash. The flush position defines the recovery point for a compute instance failure. The replay position determines what queries can see on a standby.
WAIT FOR LSN allows a session to block until one of these points reaches a target LSN:
standby_write → WAL written to disk on the standby (not yet flushed)
standby_flush → WAL flushed to durable storage on the standby
standby_replay (default) → WAL replayed into data files and visible to readers
primary_flush → WAL flushed on the primary (useful when synchronous_commit = off and a durability barrier is needed)A typical flow is to write on the primary, commit, and then fetch the current WAL insert LSN:
pg19rw=*# BEGIN;
BEGIN
pg19rw=*# INSERT INTO orders VALUES (123, 'widget');
INSERT 0 1
pg19rw=*# COMMIT;
COMMIT
pg19rw=# SELECT pg_current_wal_insert_lsn();
pg_current_wal_insert_lsn
---------------------------
0/18724C0
(1 row)
That LSN is then used to block reads on a replica until it has caught up:
pg19ro=# WAIT FOR LSN '0/18724C0'
WITH (MODE 'standby_replay', TIMEOUT '2s');
This LSN‑based read‑your‑writes pattern in PostgreSQL requires extra round‑trips: capturing the LSN on the primary and explicitly waiting on the standby. For many workloads, reading from the primary is simpler and faster.
The pattern becomes valuable when expensive reads must be offloaded to replicas while still preserving read‑your‑writes semantics, or in event‑driven and CQRS designs where the LSN itself serves as a change marker for downstream consumers.
While PostgreSQL reasons in WAL positions, MongoDB tracks causality using oplog timestamps and a hybrid logical clock.
In a replica set, each write on the primary produces an entry in local.oplog.rs, a capped collection. These entries are rewritten to be idempotent (for example, $inc becomes $set) so they can be safely reapplied. Each entry carries a Hybrid Logical Clock (HLC) timestamp that combines physical time with a logical counter, producing a monotonically increasing cluster time. Replica set members apply oplog entries in timestamp order.
Because MongoDB allows concurrent writes, temporary “oplog holes” can appear: a write with a later timestamp may commit before another write with an earlier timestamp. A naïve reader scanning the oplog could skip the earlier operation.
MongoDB prevents this by tracking an oplogReadTimestamp, the highest hole‑free point in the oplog. Secondaries are prevented from reading past this point until all prior operations are visible, ensuring causal consistency even in the presence of concurrent commits.
Causal consistency in MongoDB is enforced by attaching an afterClusterTime to reads:
operationTime of the last operation in a session.
causalConsistency: true, the driver automatically includes an afterClusterTime equal to the highest known cluster time on subsequent reads.
afterClusterTime.With any read preference that allows reading from secondaries as well as the primary, this guarantees read‑your‑writes behavior:
// Start a causally consistent session
const session = client.startSession({ causalConsistency: true });
const coll = db.collection("orders");
// Write in this session
await coll.insertOne({ id: 123, product: "widget" }, { session });
// The driver automatically injects afterClusterTime into the read concern
const order = await coll.findOne({ id: 123 }, { session });
Causal consistency is not limited to snapshot reads. It applies across read concern levels. The key point is that the session ensures later reads observe at least the effects of earlier writes, regardless of which replica serves the read.
Here is a simplified comparison:
| Feature | PostgreSQL WAIT FOR LSN
|
MongoDB Causal Consistency |
|---|---|---|
| Clock type | Physical byte offset in the WAL (LSN) | Hybrid Logical Clock (HLC) |
| Mechanism | Block until replay/write/flush LSN reached | Block until afterClusterTime is visible |
| Tracking | Application captures LSN | Driver tracks operationTime
|
| Granularity | WAL record position | Oplog timestamp |
| Replication model | Physical streaming | Logical oplog application |
| Hole handling | N/A (serialized WAL) | oplogReadTimestamp |
| Failover handling | Error unless NO_THROW
|
Session continues, bounded by replication state |
Both PostgreSQL’s WAIT FOR LSN and MongoDB’s causal consistency ensure reads can observe prior writes, but at different layers:
If you want read‑your‑writes semantics to “just work” without additional coordination calls, MongoDB’s session‑based model is a strong fit. Despite persistent myths about consistency, MongoDB delivers strong consistency in a horizontally scalable system with a simple developer experience.
I remember the early 2010s as the golden age of productivity hacking. Lifehacker, 37signals, and their ilk were everywhere, and it felt like everyone was working on jury-rigging color-coded Moleskine task-trackers and web apps into the perfect Getting Things Done system.
So recently I found myself wondering: what happened to all that excitement? Did I just outgrow the productivity movement, or did the movement itself lose stream?
After poking around a bit, I think it's both. We collectively grew out of that phase, and productivity itself fundamentally changed.
Back then, the underlying promise of productivity culture was about outputmaxxing (as we would now call it). We obsessed over efficiency at the margins: how to auto-sync this app with that one, or how to shave 5 seconds off an email reply. We accumulated systems, hacks, and integrations like collectors.
Eventually, the whole thing got exhausting. I think we all realized that tweaking task managers wasn't helping the bottom line. We were doing a lot of organizing, but that organizing wasn't reflecting in actually getting the work done.
The reason is simple: not all tasks matter equally. Making some tasks faster does not move the bottomline if the core task remains the serial bottleneck. Amdahl’s Law says that speeding up one part of a system improves overall performance only in proportion to the time that part consumes. If the hard, irreducible core is untouched, optimizations elsewhere are just noise.
Painting the deck of a sinking ship faster doesn't help anyone. Productivity should be about making sure we are working on the right things in the first place. The main thing is to keep the main thing the main thing.
For more than 15 years, I've relied on Emacs org-mode to run my life. It's the ultimate organization system, that has survived every software trend of the past decade and a half. But despite having this powerful writing system at my fingertips, my best ideas never arrive while I'm staring at a screen. Almost without exception, my hard thinking happens away from the screen. That's where the ideas come from.
If I'm being rational about it: I should be paid for the time I spend thinking hard, not for the time I spend managing my inbox, or doing trivial office work, or wrangling text on a screen.
So that's how I try to work now. I do my deep thinking, messy brainstorming, and wrestling-with-ideas completely away from the screen. Then I plan my next 45 minutes or so (what I'm going to do, in what order, and why) and only then do I go to my laptop to execute it. In other words, I arrive at the screen with a plan.
(OK, let's first take a moment to appreciate my self-restraint for not mentioning AI until this late in to the post. But here it comes.)
What does productivity even mean in the age of AI? What are we actually here to contribute? Are we supposed to be architects or butlers to LLMs?
If AI absorbs all the shallow work, the only things left that genuinely require a human are the core parts that demands genuine creativity, judgment, taste, and the type of thinking that can't be prompted away. This raises the stakes considerably, and changes what "a productive day" even means.
That kind of deep creative work is best done away from the glowing rectangle.
I recently launched a free email newsletter for the blog. Subscribe here to get these essays delivered to your inbox, along with behind-the-scenes commentary.
A document database is more than a JSON datastore. It must also support efficient storage and advanced search: equality and range predicates, fuzzy text search, ranking, pagination, and limited sorted results (top‑k). BM25 indexes, which combine an inverted index and columnar doc values, are ideal for this, with mature open‑source implementations like Lucene (used by MongoDB) and Tantivy (used by ParadeDB).
ParadeDB brings Tantivy indexing to PostgreSQL via the pg_search extension and recently published an excellent article showing where GIN indexes fall short and how BM25 bridges the gap. Here, I’ll present the MongoDB equivalent using its Lucene‑based search indexes. I suggest reading ParadeDB’s post first, as it clearly explains the problem and the solution:
I'll be lazy and use the same dataset, index and query.
You can use BM25 indexes on MongoDB in several environments: the cloud-managed service (MongoDB Atlas), its local deployment (Atlas Local), on-premises MongoDB Enterprise Server, and the open-source MongoDB Community edition. The mongot engine that powers MongoDB Search is in public preview, with its source available at github.com/mongodb/mongot.
I started a local Atlas deployment on my laptop with Atlas CLI and connected automatically:
atlas deployments setup mongo --type local --connectWith mongosh --force
I generated 100,000,000 documents similar to ParadeDB's benchmark:
const batchSize = 10000;
const batches = 10000;
const rows = batches * batchSize;
print(`Generating ${rows.toLocaleString()} documents`);
db.benchmark_logs.drop();
const messages = [ 'The research team discovered a new species of deep-sea creature while conducting experiments near hydrothermal vents in the dark ocean depths.', 'The research facility analyzed samples from ancient artifacts, revealing breakthrough findings about civilizations lost to the depths of time.', 'The research station monitored weather patterns across mountain peaks, collecting data about atmospheric changes in the remote depths below.', 'The research observatory captured images of stellar phenomena, peering into the cosmic depths to understand the mysteries of distant galaxies.', 'The research laboratory processed vast amounts of genetic data, exploring the molecular depths of DNA to unlock biological secrets.', 'The research center studied rare organisms found in ocean depths, documenting new species thriving in extreme underwater environments.', 'The research institute developed quantum systems to probe subatomic depths, advancing our understanding of fundamental particle physics.', 'The research expedition explored underwater depths near volcanic vents, discovering unique ecosystems adapted to extreme conditions.', 'The research facility conducted experiments in the depths of space, testing how different materials behave in zero gravity environments.', 'The research team engineered crops that could grow in the depths of drought conditions, helping communities facing climate challenges.' ];
const countries = [ 'United States', 'Canada', 'United Kingdom', 'France', 'Germany', 'Japan', 'Australia', 'Brazil', 'India', 'China' ];
const labels = [ 'critical system alert', 'routine maintenance', 'security notification', 'performance metric', 'user activity', 'system status', 'network event', 'application log', 'database operation', 'authentication event' ];
let batch = [];
const startDate = new Date("2020-01-01T00:00:00Z");
for (let i = 0; i < rows; i++) {
batch.push({
message: messages[i % 10],
country: countries[i % 10],
severity: (i % 5) + 1,
timestamp: new Date(startDate.getTime() + (i % 731) * 24 * 60 * 60 * 1000),
metadata: {
value: (i % 1000) + 1,
label: labels[i % 10]
}
});
if (batch.length === batchSize) {
db.benchmark_logs.insertMany(batch);
batch = [];
}
}
I checked the document schema and counts:
print(`Done!
\nSample: ${EJSON.stringify( db.benchmark_logs.find().limit(1).toArray(), null, 2 )}
\nDocument count: ${db.benchmark_logs.countDocuments().toLocaleString()}
`);
Sample: [
{
"_id": {
"$oid": "6997580679ab8450f81ff93c"
},
"message": "The research team discovered a new species of deep-sea creature while conducting experiments near hydrothermal vents in the dark ocean depths.",
"country": "United States",
"severity": 1,
"timestamp": {
"$date": "2020-01-01T00:00:00Z"
},
"metadata": {
"value": 1,
"label": "critical system alert"
}
}
]
Document count: 100,000,000
With 100 million documents, this is a large dataset. Because many fields can be queried, we can’t create every compound index combination. A single search index will make queries on this collection efficient.
I created the search index similar to the one used on ParadeDB (here):
const mapping = {
mappings: {
// Equivalent to: USING bm25 Atlas Search uses Lucene BM25 by default
dynamic: false,
fields: {
// Equivalent to: bm25(id, message, ...) Standard full-text field scored by BM25
message: { type: "string" },
// Equivalent to: text_fields = { "country": { fast: true, tokenizer: { type: "raw", lowercase: true } } } // fast = true → implicit in Atlas Search; docValues optional in cloud
country: { type: "string", analyzer: "keywordLowercase" },
// Equivalent to:numeric field indexed for filtering
severity: { type: "number", representation: "int64" },
// Equivalent to:timestamp field included in the BM25 index
timestamp: { type: "date" },
// Equivalent to: json_fields = { "metadata": { fast: true, tokenizer: raw } }
metadata: {
type: "document",
fields: {
value: {
type: "number",
representation: "int64"
},
// Equivalent to: metadata tokenizer = raw + lowercase
label: {
type: "string",
analyzer: "keywordLowercase"
}
}
}
}
},
analyzers: [
{
// Equivalent to: tokenizer = raw, lowercase = true
name: "keywordLowercase",
tokenizer: { type: "keyword" },
tokenFilters: [{ type: "lowercase" }]
}
]
};
db.benchmark_logs.createSearchIndex(
"benchmark_logs_idx",
mapping
);
The index is created asynchronously and updated via change stream operations.
The query combines text search, range filter, sort by score, and limit for Top-K:
query = [
{
$search: {
index: "benchmark_logs_idx",
compound: {
must: [{ text: { query: "research team", path: "message" } }],
filter: [{ range: { path: "severity", lt: 3 } }]
},
sort: { score: { $meta: "searchScore" } }
}
},
{ $limit: 10 },
{
$project: {
message: 1,
country: 1,
severity: 1,
timestamp: 1,
metadata: 1,
rank: { $meta: "searchScore" }
}
}
]
const start = Date.now();
print(EJSON.stringify(db.benchmark_logs.aggregate(query).toArray(),null,2));
const end = Date.now();
print(`\nExecution time: ${end - start} ms`);
It is important that the sort is part of $search because an additional $sort stage would not be pushed down. This allows Atlas Search to run the query in Lucene’s Top‑K mode, enabling block‑max WAND (BMW) pruning via competitive score feedback during collection.
Here is the result and timing:
[{"_id":{"$oid":"699757049ce6a7c42c65d105"},"message":"The research team discovered a new species of deep-sea creature while conducting experiments near hydrothermal vents in the dark ocean depths.","country":"United States","severity":1,"timestamp":{"$date":"2020-01-11T00:00:00Z"},"metadata":{"value":11,"label":"critical system alert"},"rank":0.6839379072189331},{"_id":{"$oid":"699757049ce6a7c42c65d10f"},"message":"The research team discovered a new species of deep-sea creature while conducting experiments near hydrothermal vents in the dark ocean depths.","country":"United States","severity":1,"timestamp":{"$date":"2020-01-21T00:00:00Z"},"metadata":{"value":21,"label":"critical system alert"},"rank":0.6839379072189331},{"_id":{"$oid":"699757049ce6a7c42c65d119"},"message":"The research team discovered a new species of deep-sea creature while conducting experiments near hydrothermal vents in the dark ocean depths.","country":"United States","severity":1,"timestamp":{"$date":"2020-01-31T00:00:00Z"},"metadata":{"value":31,"label":"critical system alert"},"rank":0.6839379072189331},{"_id":{"$oid":"699757049ce6a7c42c65d123"},"message":"The research team discovered a new species of deep-sea creature while conducting experiments near hydrothermal vents in the dark ocean depths.","country":"United States","severity":1,"timestamp":{"$date":"2020-02-10T00:00:00Z"},"metadata":{"value":41,"label":"critical system alert"},"rank":0.6839379072189331},{"_id":{"$oid":"699757049ce6a7c42c65d12d"},"message":"The research team discovered a new species of deep-sea creature while conducting experiments near hydrothermal vents in the dark ocean depths.","country":"United States","severity":1,"timestamp":{"$date":"2020-02-20T00:00:00Z"},"metadata":{"value":51,"label":"critical system alert"},"rank":0.6839379072189331},{"_id":{"$oid":"699757049ce6a7c42c65d137"},"message":"The research team discovered a new species of deep-sea creature while conducting experiments near hydrothermal vents in the dark ocean depths.","country":"United States","severity":1,"timestamp":{"$date":"2020-03-01T00:00:00Z"},"metadata":{"value":61,"label":"critical system alert"},"rank":0.6839379072189331},{"_id":{"$oid":"699757049ce6a7c42c65d141"},"message":"The research team discovered a new species of deep-sea creature while conducting experiments near hydrothermal vents in the dark ocean depths.","country":"United States","severity":1,"timestamp":{"$date":"2020-03-11T00:00:00Z"},"metadata":{"value":71,"label":"critical system alert"},"rank":0.6839379072189331},{"_id":{"$oid":"699757049ce6a7c42c65d14b"},"message":"The research team discovered a new species of deep-sea creature while conducting experiments near hydrothermal vents in the dark ocean depths.","country":"United States","severity":1,"timestamp":{"$date":"2020-03-21T00:00:00Z"},"metadata":{"value":81,"label":"critical system alert"},"rank":0.6839379072189331},{"_id":{"$oid":"699757049ce6a7c42c65d155"},"message":"The research team discovered a new species of deep-sea creature while conducting experiments near hydrothermal vents in the dark ocean depths.","country":"United States","severity":1,"timestamp":{"$date":"2020-03-31T00:00:00Z"},"metadata":{"value":91,"label":"critical system alert"},"rank":0.6839379072189331},{"_id":{"$oid":"699757049ce6a7c42c65d15f"},"message":"The research team discovered a new species of deep-sea creature while conducting experiments near hydrothermal vents in the dark ocean depths.","country":"United States","severity":1,"timestamp":{"$date":"2020-04-10T00:00:00Z"},"metadata":{"value":101,"label":"critical system alert"},"rank":0.6839379072189331}]
Execution time: 1850 ms
On my laptop, this search over 100 million documents returns results in under two seconds, with no tuning. It performs a broad text match, and the high‑frequency terms "research" and "team" generate tens of millions of candidate documents. The additional severity filter and scoring require comparing tens of millions of scores, which has been heavily parallelized to stay within the two‑second budget.
Because the execution plan is long, I’ve packed it into a short string that you can easily copy and paste into your preferred AI chatbot:
EJSON.stringify(
db.benchmark_logs.aggregate(query).explain("executionStats")
);
{"explainVersion":"1","stages":[{"$_internalSearchMongotRemote":{"mongotQuery":{"index":"benchmark_logs_idx","compound":{"must":[{"text":{"query":"research team","path":"message"}}],"filter":[{"range":{"path":"severity","lt":3}}]},"sort":{"score":{"$meta":"searchScore"}}},"explain":{"query":{"type":"BooleanQuery","args":{"must":[{"path":"compound.must","type":"BooleanQuery","args":{"must":[],"mustNot":[],"should":[{"type":"TermQuery","args":{"path":"message","value":"research"},"stats":{"context":{"millisElapsed":1.273251,"invocationCounts":{"createWeight":2,"createScorer":87}},"match":{"millisElapsed":0},"score":{"millisElapsed":1292.607756,"invocationCounts":{"score":40000011}}}},{"type":"TermQuery","args":{"path":"message","value":"team"},"stats":{"context":{"millisElapsed":0.292666,"invocationCounts":{"createWeight":2,"createScorer":87}},"match":{"millisElapsed":0},"score":{"millisElapsed":379.190071,"invocationCounts":{"score":10000011}}}}],"filter":[],"minimumShouldMatch":0},"stats":{"context":{"millisElapsed":2.268162,"invocationCounts":{"createWeight":2,"createScorer":87}},"match":{"millisElapsed":0},"score":{"millisElapsed":3838.859709,"invocationCounts":{"score":40000011}}}}],"mustNot
A Guide to Accelerating Your Application with Valkey: Caching Database Queries and Sessions
Modern applications often rely on multiple services to provide fast, reliable, and scalable responses. A common and highly effective architecture involves an application, a persistent database (like MySQL), and a high-speed cache service (like Valkey). In this guide, we’ll explore how to integrate these components effectively using Python to dramatically improve your application’s performance. Understanding […]
How We Built Tinybird's TypeScript SDK for ClickHouse
How we built the Tinybird TypeScript SDK: phantom types for compile-time inference, esbuild for schema loading, and a dev workflow that connects your app and data layer.
Faster PlanetScale Postgres connections with Cloudflare Hyperdrive
Build a real-time application with PlanetScale and the Cloudflare global network. Infrastructure choices you won't need to migrate away from once you hit scale.
The Crux: Fairness Over Speed. Unlike the schedulers we explored in Chapter 8 (like Shortest Job First or Multi-Level Feedback Queues) that optimize for "turnaround time" or "response time", proportional-share schedulers introduced in this Chapter aim to guarantee that each job receives a specific percentage of CPU time.
(This is part of our series going through OSTEP book chapters.)
Lottery Scheduling serves as the foundational example of proportional-share schedulers. It uses a randomized mechanism to achieve fairness probabilistically. The central concept of Lottery Scheduling is the ticket. Tickets represent the share of the resource a process should receive.
The scheduler holds a lottery every time slice. If Job A has 75 tickets and Job B has 25 (100 total), the scheduler picks a random number between 0 and 99. Statistically, Job A will win 75% of the time. The implementation is incredibly simple. It requires a random number generator, a list of processes, and a loop that sums ticket values until the randomly picked counter (300 in the below example) exceeds the winning number.
Advanced Ticket Mechanisms
1. Ticket Currency: Users can allocate tickets among their own jobs in local currency (e.g., 500 "Alice-tickets"), which the system converts to global currency. This delegates the "fairness" decision to the user.
2. Ticket Transfer: A client can temporarily hand its tickets to a server process to maximize performance while a specific request is being handled.
3. Ticket Inflation: In trusted environments, a process can unilaterally boost its ticket count to reflect a higher need for CPU. In competitive settings this is unsafe, since a greedy process could grant itself excessive tickets and monopolize the machine. In practice, modern systems prevent this with control groups (cgroups), which act as an external regulator that assigns fixed resource weights so untrusted processes cannot simply print more tickets to override the scheduler.
Lottery Scheduling depends on randomness to decide which job runs next. This randomness helps avoid the tricky cases that can trip up traditional algorithms, like LRU on cyclic workloads, and keeps the scheduler simple with minimal state to track. However, fairness is only achieved over time. In the short term, a job might get unlucky and lose more often than its share of tickets. Studies show that fairness is low for short jobs and only approaches perfect fairness as the total runtime increases.
Stride Scheduling emerged to address the probabilistic quirks of Lottery Scheduling. It assigns each process a stride, inversely proportional to its tickets, and maintains a pass value tracking how much CPU time the process has received. At each decision point, the scheduler selects the process with the lowest pass value.
This guarantees exact fairness each cycle, but it introduces challenges with global state. When a new process arrives, assigning it a fair initial pass value is tricky: set it too low, and it can dominate the CPU; too high, and it risks starvation. In contrast, Lottery Scheduling handles new arrivals seamlessly, since it requires no global state.
The Linux Completely Fair Scheduler (CFS) builds on these earlier proportional schedulers but removes randomness by using Virtual Runtime (vruntime) to track each process’s CPU usage. At every scheduling decision, CFS selects the job with the smallest vruntime, ensuring a fair distribution of CPU time. To prevent excessive context-switching overhead when there are many tasks (each receiving only a tiny slice of CPU time), CFS enforces a min_granularity. This ensures every process runs for at least a minimum time slice, and it balances fairness with efficient CPU utilization.
To prioritize specific processes, CFS uses the classic UNIX "nice" level, which allows users to assign values between -20 (highest priority) and +19 (lowest priority). CFS maps these values to geometric weights; a process with a higher priority (lower nice value) is assigned a larger weight. This weight directly alters the rate at which vruntime accumulates: high-priority processes add to their vruntime much more slowly than low-priority ones. When determining exactly how long a process should run within the target scheduling latency (sched_latency), instead of simply dividing the target latency equally among all tasks (e.g., 48ms/n), CFS calculates the time slice for a specific process k as a fraction of the total weight of all currently running processes.
Consequently, a high-priority job can run for a longer physical time while only "charging" a small amount of virtual time, allowing it to claim a larger proportional share of the CPU compared to "nicer" low-priority tasks.
Finally, because modern systems handle thousands of processes, CFS replaces the simple lists of Lottery Scheduling with Red-Black Trees, giving $O(\log n)$ efficiency for insertion and selection.
The I/O Problem. Proportional schedulers face a challenge when jobs sleep, such as waiting for I/O. In a straightforward model, a sleeping job lags behind, and when it resumes, it can monopolize the CPU to catch up, potentially starving other processes. CFS addresses this by resetting the waking job’s vruntime to the minimum value in the tree. This ensures no process starves, but it can penalize the interactive job, leading to slower response times.
The Ticket Assignment Problem. Assigning tickets is still an open challenge. In general-purpose computing, such as browsers or editors, it’s unclear how many tickets each application deserves, making fairness difficult to enforce. The situation is a bit more clear in virtualization and cloud computing, where ticket allocation aligns naturally with resource usage: if a client pays for 25% of a server, it can be assigned 25% of the tickets, providing a clear and effective proportional share.
Throughput for the write-heavy steps of the Insert Benchmark look like a distorted sine wave with Postgres on CPU-bound workloads but not on IO-bound workloads. For the CPU-bound workloads the chart for max response time at N-second intervals for inserts is flat but for deletes it looks like the distorted sine wave. To see the chart for deletes, scroll down from here. So this looks like a problem for deletes and this post starts to explain that.
tl;dr
History of the Insert Benchmark
Long ago (prior to 2010) the Insert Benchmark was published by Tokutek to highlight things that the TokuDB storage engine was great at. I was working on MySQL at Google at the time and the benchmark was useful to me, however it was written in C++. While the Insert Benchmark is great at showing the benefits of an LSM storage engine, this was years before MyRocks and I was only doing InnoDB at the time, on spinning disks. So I rewrote it in Python to make it easier to modify, and then the Tokutek team improved a few things about my rewrite, and I have been enhancing it slowly since then.
Until a few years ago the steps of the benchmark were:
MariaDB 12.3 has a new feature enabled by the option binlog_storage_engine. When enabled it uses InnoDB instead of raw files to store the binlog. A big benefit from this is reducing the number of fsync calls per commit from 2 to 1 because it reduces the number of resource managers from 2 (binlog, InnoDB) to 1 (InnoDB).
My previous post had results for sysbench with a small server. This post has results for the Insert Benchmark with a similar small server. Both servers use an SSD that has has high fsync latency. This is probably a best-case comparison for the feature. If you really care, then get enterprise SSDs with power loss protection. But you might encounter high fsync latency on public cloud servers.
tl;dr for a CPU-bound workload
| dbms | l.i0 | l.x | l.i1 | l.i2 | qr100 | qp100 | qr500 | qp500 | qr1000 | qp1000 |
|---|---|---|---|---|---|---|---|---|---|---|
| ma120300_rel_withdbg.cz12b_c8r32 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 |
| ma120300_rel_withdbg.cz12c_c8r32 | 1.03 | 1.01 | 1.00 | 1.03 | 1.00 | 0.99 | 1.00 | 1.00 | 1.01 | 1.00 |
| ma120300_rel_withdbg.cz12b_sync_c8r32 | 0.04 | 1.02 | 0.07 | 0.01 | 1.01 | 1.01 | 1.00 | 1.01 | 1.00 | 1.00 |
| ma120300_rel_withdbg.cz12c_sync_c8r32 | 0.08 | 1.03 | 0.28 | 0.06 | 1.02 | 1.01 | 1.01 | 1.02 | 1.02 | 1.01 |
| dbms | l.i0 | l.x | l.i1 | l.i2 | qr100 | qp100 | qr500 | qp500 | qr1000 | qp1000 |
|---|---|---|---|---|---|---|---|---|---|---|
| ma120300_rel_withdbg.cz12b_sync_c8r32 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 |
| ma120300_rel_withdbg.cz12c_sync_c8r32 | 1.75 | 1.01 | 3.99 | 6.83 | 1.01 | 1.01 | 1.01 | 1.01 | 1.03 | 1.01 |
| dbms | l.i0 | l.x | l.i1 | l.i2 | qr100 | qp100 | qr500 | qp500 | qr1000 | qp1000 |
|---|---|---|---|---|---|---|---|---|---|---|
| ma120300_rel_withdbg.cz12b_c8r32 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 |
| ma120300_rel_withdbg.cz12c_c8r32 | 1.01 | 0.99 | 0.99 | 1.01 | 1.01 | 1.01 | 1.01 | 1.07 | 1.01 | 1.04 |
| ma120300_rel_withdbg.cz12b_sync_c8r32 | 0.04 | 1.00 | 0.55 | 0.10 | 1.02 | 0.97 | 1.00 | 0.80 | 0.95 | 0.55 |
| ma120300_rel_withdbg.cz12c_sync_c8r32 | 0.18 | 1.00 | 0.83 | 0.31 | 1.02 | 1.01 | 1.02 | 0.96 | 1.02 | 0.86 |
| dbms | l.i0 | l.x | l.i1 | l.i2 | qr100 | qp100 | qr500 | qp500 | qr1000 | qp1000 |
|---|---|---|---|---|---|---|---|---|---|---|
| ma120300_rel_withdbg.cz12b_sync_c8r32 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 |
| ma120300_rel_withdbg.cz12c_sync_c8r32 | 4.74 | 1.00 | 1.50 | 2.99 | 1.00 | 1.04 | 1.02 | 1.20 | 1.08 | 1.57 |
MariaDB 12.3 has a new feature enabled by the option binlog_storage_engine. When enabled it uses InnoDB instead of raw files to store the binlog. A big benefit from this is reducing the number of fsync calls per commit from 2 to 1 because it reduces the number of resource managers from 2 (binlog, InnoDB) to 1 (InnoDB).
In this post I have results for the performance benefit from this when using storage that has a high fsync latency. This is probably a best-case comparison for the feature. A future post will cover the benefit on servers that don't have high fsync latency.
tl;dr
Benchmark
(QPS for some version) / (QPS for base version)
Relational composition is to joins what the cartesian product is to tables: it produces every result that could be true, not just what is true. This often leads to SQL mistakes and can often be suspected when a SELECT DISTINCT is added after a query starts returning more rows than expected, without the root cause being understood.
In its mathematical definition, relational composition is the derived relation obtained by existentially joining two relations on a shared attribute and projecting away that attribute. In a database, it is meaningful only when a real‑world invariant ensures that the resulting pairs reflect actual facts. Otherwise, the result illustrates what E. F. Codd, in his 1970 paper A Relational Model of Data for Large Shared Data Banks, called the connection trap.
Codd uses two relations in his example: a supplier supplies parts, and a project uses parts. At an intuitive level, this connection trap mirrors a syllogism: if a supplier supplies a part and a project uses that part, a join can derive that the supplier supplies the project—even when that conclusion was never stated as a fact.
Codd observed that the connection trap was common in pre‑relational network data models, where users navigated data by following physical pointers. Path existence was often mistaken for semantic relationship. The relational model solved this problem by replacing navigational access with declarative queries over explicitly defined relations, and modern document models now do the same.
However, while the relational model removes pointer‑based navigation, it does not eliminate the trap entirely. Joins can still compute relational compositions, and without appropriate cardinality constraints or business invariants, such compositions may represent only possible relationships rather than actual ones. In this way, the connection trap can be reintroduced at query time, even in modern relational systems such as PostgreSQL, and similarly through $lookup operations in MongoDB.
This model declares suppliers, parts, projects, and two independent many‑to‑many relationships:
CREATE TABLE suppliers (
supplier_id TEXT PRIMARY KEY
);
CREATE TABLE parts (
part_id TEXT PRIMARY KEY
);
CREATE TABLE projects (
project_id TEXT PRIMARY KEY
);
-- Supplier supplies parts
CREATE TABLE supplier_part (
supplier_id TEXT REFERENCES suppliers,
part_id TEXT REFERENCES parts,
PRIMARY KEY (supplier_id, part_id)
);
-- Project uses parts
CREATE TABLE project_part (
project_id TEXT REFERENCES projects,
part_id TEXT REFERENCES parts,
PRIMARY KEY (project_id, part_id)
);
This follows Codd’s classic suppliers–parts–projects example, where suppliers supply parts and projects use parts as independent relationships.
The following data asserts that project Alpha uses parts P1 and P2, that supplier S1 supplies parts P1 and P2, and that supplier S2 supplies parts P2 and P3:
INSERT INTO suppliers VALUES ('S1'), ('S2');
INSERT INTO parts VALUES ('P1'), ('P2'), ('P3');
INSERT INTO projects VALUES ('Alpha');
-- Supplier capabilities
INSERT INTO supplier_part VALUES
('S1', 'P1'),
('S1', 'P2'),
('S2', 'P2'),
('S2', 'P3');
-- Project uses parts P1 and P2
INSERT INTO project_part VALUES
('Alpha', 'P1'),
('Alpha', 'P2');
The following query is valid SQL:
SELECT DISTINCT
sp.supplier_id,
pp.project_id
FROM supplier_part sp
JOIN project_part pp
ON sp.part_id = pp.part_id;
However, this query falls into the connection trap:
supplier_id | project_id
-------------+------------
S2 | Alpha
S1 | Alpha
(2 rows)
As we defined only supplier–part and project–part relationships, any derived supplier–project relationship is not a fact but a relational composition. We know that Alpha uses P1 and P2, and that part P2 can be supplied by either S1 or S2, but we have no record of which supplier actually supplies Alpha.
This query asserts “Supplier S1 supplies project Alpha”, but the data only says: “S1 and S2 supply P2” and “Alpha uses P2”.
This is the connection trap, expressed purely in SQL.
If a supplier actually supplies a part to a project, that fact must be represented directly. We need a new table:
CREATE TABLE supply (
supplier_id TEXT,
project_id TEXT,
part_id TEXT,
PRIMARY KEY (supplier_id, project_id, part_id),
FOREIGN KEY (supplier_id, part_id)
REFERENCES supplier_part (supplier_id, part_id),
FOREIGN KEY (project_id, part_id)
REFERENCES project_part (project_id, part_id)
);
These foreign keys encode subset constraints between relations and prevent inserting supplies of parts not supplied by the supplier or not used by the project.
This relation explicitly states who supplies what to which project. We assume that the real‑world fact is “Alpha gets part P2 from supplier S1”:
INSERT INTO supply VALUES
('S1', 'Alpha', 'P2');
The correct query reads from this relation:
SELECT supplier_id, project_id
FROM supply;
supplier_id | project_id
-------------+------------
S1 | Alpha
(1 row)
The relationship is now real and asserted, not inferred. In total, we have six tables:
postgres=# \d
List of relations
Schema | Name | Type | Owner
--------+---------------+-------+----------
public | parts | table | postgres
public | project_part | table | postgres
public | projects | table | postgres
public | supplier_part | table | postgres
public | suppliers | table | postgres
public | supply | table | postgres
(6 rows)
In practice, you should either store the relationship explicitly or avoid claiming it exists. Although the relational model avoids pointers, it is still possible to join through an incorrect path, so the application must enforce the correct one.
In ad-hoc query environments such as data warehouses, data is typically organized into domains and modeled using a dimensional ("star schema") approach. Relationships like project–supplier are represented as fact tables within a single data mart, exposing only semantically valid join paths and preventing invalid joins.
The following MongoDB data mirrors the PostgreSQL example. MongoDB allows representing relationships either as separate collections or by embedding, depending on the bounded context. Here we start with separate collections to mirror the relational model:
db.suppliers.insertMany([
{ _id: "S1" },
{ _id: "S2" }
]);
db.parts.insertMany([
{ _id: "P1" },
{ _id: "P2" },
{ _id: "P3" }
]);
db.projects.insertMany([
{ _id: "Alpha" }
]);
// Supplier capabilities
db.supplier_parts.insertMany([
{ supplier: "S1", part: "P1" },
{ supplier: "S1", part: "P2" },
{ supplier: "S2", part: "P2" },
{ supplier: "S2", part: "P3" }
]);
// Project uses parts P1 and P2
db.project_parts.insertMany([
{ project: "Alpha", part: "P1" },
{ project: "Alpha", part: "P2" }
]);
Using the simple find() API, we cannot fall into the trap directly because there is no implicit connection between suppliers and projects. The application must issue two independent queries and combine the results explicitly.
Simulating the connection trap in a single query therefore requires explicit composition at the application level:
const partsUsedByAlpha = db.project_parts.find(
{ project: "Alpha" },
{ _id: 0, part: 1 }
).toArray();
const suppliersForParts = db.supplier_parts.find(
{ part: { $in: partsUsedByAlpha.map(p => p.part) } },
{ _id: 0, supplier: 1, part: 1 }
).toArray();
const supplierProjectPairs = suppliersForParts.map(sp => ({
supplier: sp.supplier,
project: "Alpha"
}));
print(supplierProjectPairs);
When forced by the application logic, here is the connection trap associating suppliers and projects:
[
{ supplier: 'S1', project: 'Alpha' },
{ supplier: 'S1', project: 'Alpha' },
{ supplier: 'S2', project: 'Alpha' }
]
As with SQL joins, a $lookup in an aggregation pipeline can fall in the same connection trap:
db.supplier_parts.aggregate([
{
$lookup: {
from: "project_parts",
localField: "part",
foreignField: "part",
as: "projects"
}
},
{ $unwind: "$projects" },
{
$project: {
_id: 0,
supplier: "$supplier",
project: "$projects.project"
}
}
]);
The result is similar and the projection removed the intermediate attributes:
{ "supplier": "S1", "project": "Alpha" }
{ "supplier": "S1", "project": "Alpha" }
{ "supplier": "S2", "project": "Alpha" }
We reproduced the connection trap by ignoring that $lookup produces a derived relationship, not a real one, and that matching keys does not carry business meaning.
As with SQL, we can add an explicit supplies collection that stores the relationship between projects and suppliers:
db.supplies.insertOne({
project: "Alpha",
supplier: "S1",
part: "P2"
});
Then we simply query this collection:
db.supplies.find(
{ project: "Alpha" },
{ _id: 0, supplier: 1, part: 1 }
);
[ { supplier: 'S1', part: 'P2' } ]
The document model is a superset of the relational model as relations can be stored as flat collections. The difference is that referential integrity is enforced by the application rather than in-database. To enforce relationships in the database, they must be embedded as sub-documents and arrays.
It's not the only solution in a document database, as we can store a schema based on the domain model rather than normalized. MongoDB allows representing this relationship as part of an aggregate. In a project‑centric bounded context, the project is the aggregate root, and the supplier information can be embedded as part of the supply fact:
db.projects.updateOne(
{ _id: "Alpha" },
{
$set: {
parts: [
{ part: "P2", supplier: "S1" },
{ part: "P1", supplier: null }
]
}
},
{ upsert: true }
);
The query doesn't need a join and cannot fall into the connection trap:
db.projects.find(
{ _id: "Alpha" },
{ _id: 1, "parts.supplier": 1 }
);
[
{
_id: 'Alpha',
parts: [
{ supplier: 'S1' },
{ supplier: null }
]
}
]
This avoids the connection trap by construction. It may look like data duplication—the same supplier name may appear in multiple project documents—and indeed this would be undesirable in a fully normalized model shared across all business domains. However, this structure represents a valid aggregate within a bounded context.
In this context, the embedded supplier information is part of the supply fact, not a reference to a global supplier record. If a supplier’s name changes, it is a business decision, not a database decision, whether that change should be propagated to existing projects or whether historical data should retain the supplier name as it was at the time of supply.
Even when propagation is desired, MongoDB allows updating embedded data efficiently:
db.projects.updateMany(
// document filter
{ "parts.supplier": "S1" },
// document update using the array's item from array filter
{
$set: {
"parts.$[p].supplier": "Supplier One"
}
},
// array filter defining the array's item for the update
{
arrayFilters: [{ "p.supplier": "S1" }]
}
);
This update is not atomic across documents, but each document update is atomic and the operation is idempotent and can be safely retried or executed within an explicit transaction if full atomicity is required.
The connection trap occurs whenever relationships are inferred from shared keys, at query time, instead of being explicitly represented as facts, at write time. In SQL, this means introducing explicit association tables and enforcing integrity constraints, rather than deriving then though joins. In MongoDB, it means modeling relationships as explicit documents or embedded subdocuments rather than deriving them through lookups.
In a relational database, the schema is designed to be normalized and independent of specific use cases. All many‑to‑many and fact‑bearing relationships must be declared explicitly, and queries must follow the correct relational path. Referential and cardinality constraints are essential to restrict to only actual facts.
In MongoDB, the data model is typically driven by the domain and the application’s use cases. In a domain-driven design (DDD), strong relationships are modeled as aggregates, embedding related data directly within a document in MongoDB collections. This makes the intended semantics explicit and avoids inferring relationships at query time. Apparent duplication is not a flaw here, but a deliberate modeling choice within a bounded context.
Ultimately, the connection trap is not fully avoided by the data model, but can be a query-time error with joins and projections: deriving relationships that were never asserted. Whether using normalized relations or domain‑driven documents, the rule is the same—if a relationship is a fact, it must be stored as one.
This has results for HammerDB tproc-c on a small server using MySQL and Postgres. I am new to HammerDB and still figuring out how to explain and present results so I will keep this simple and just share graphs without explaining the results.
The comparison might favor Postgres for the IO-bound workloads because I used smaller buffer pools than normal to avoid OOM. I have to do this because RSS for the HammerDB client grows over time as it buffers more response time stats. And while I used buffered IO for Postgres, I use O_DIRECT for InnoDB. So Postgres might have avoided some read IO thanks to the OS page cache while InnoDB did not.
tl;dr for MySQL
(NOPM for some-version / NOPM for base-version)
I provide three charts below:
Legend:
Summary
And the absolute NOPM values are here:
Results: Postgres 12 to 18
Legend:
Summary
by Mark Callaghan (noreply@blogger.com)
Butlers or Architects?In a recent viral post, Matt Shumer declares dramatically that we've crossed an irreversible threshold. He asserts that the latest AI models now exercise independent judgment, that he simply gives an AI plain-English instructions, steps away for a few hours, and returns to a flawlessly finished product that surpasses his own capabilities. In the near future, he claims, AI will autonomously handle all knowledge work and even build the next generation of AI itself, leaving human creators completely blindsided by the exponential curve. This was a depressing read. The dramatic tone lands well. And by extrapolating from progress in the last six years, it's hard to argue against what AI might achieve in the next six. I forwarded this to a friend of mine, who had the misfortune of reading it before bed. He told me he had a nightmare about it, dreaming of himself as an Uber driver, completely displaced from his high-tech career. Someone on Twitter had a come back: "The thing I don't get is: Claude Code is writing 100% of Claude's code now. But Anthropic has 100+ open dev positions on their jobs page?" Boris Cherny of Anthropic replied: "The reality is that someone has to prompt the Claudes, talk to customers, coordinate with other teams, and decide what to build next. Engineering is changing, and great engineers are more important than ever." This is strongly reminiscent of the Shell Game podcast I wrote about recently. And it connects to my arguments in "Agentic AI and The Mythical Agent-Month" about the mathematical laws of scaling coordination. Throwing thousands of AI agents at a project does not magically bypass Brooks' Law. Agents can dramatically scale the volume of code generated, but they do not scale insight. Coordination complexity and verification bottlenecks remain firmly in place. Until you solve the epistemic gap of distributed knowledge, adding more agents simply produces a faster, more expensive way to generate merge conflicts. Design, at its core, is still very human. Trung Phan's recent piece on how Docusign still employs 7,000 people in the age of AI provides useful context as well. Complex organizations don't dissolve overnight. Societal constructs, institutional inertia, regulatory frameworks, and the deeply human texture of business relationships all act as buffers. The world changes slower than the benchmarks suggest. So we are nowhere near a fully autonomous AI that sweeps up all knowledge work and solves everything. When we step back, two ways of reading the situation come into view. The first is that we are all becoming butlers for LLMs: priming the model, feeding it context in careful portions, adding constraints, nudging tone, coaxing the trajectory. Then stepping back to watch it cook. We do the setup and it does the real work. But as a perennial optimist, I think we are becoming architects. Deep work will not disappear, rather it will become the only work that matters. We get to design the blueprint, break down logic in high-level parts, set the vision, dictate strategy, and chart trajectory. We do the real thinking, and then we make the model grind.
In anyway, this shift brings a real danger. If we delegate execution, it becomes tempting to delegate thought gradually. LLMs make thinking feel optional. People were already reluctant to think; now they can bypass it entirely. It is unsettling to watch a statistical prediction machine stand in for reasoning. Humbling, too. Maybe we're not as special as we assumed. This reminds me Ted Chiang's story "Catching Crumbs from the Table" where humanity is reduced to interpreting the outputs of a vastly superior intellect. Human scientists no longer produce breakthroughs themselves; they spend their careers reverse-engineering discoveries made by "metahumans". The tragedy is that humans are no longer the source of the insight, they are merely trying to explain metahumans' genius. The title captures the feeling really well. We're not at the table anymore. We're just gathering what falls from it. Even if things come to that, I know I'll keep thinking, keep learning, keep striving to build things. As I reflected in an earlier post on finding one's true calling, this pursuit of knowledge and creation is my dharma. That basic human drive to understand things and build things is not something an LLM can automate away. This I believe. I recently launched a free email newsletter for the blog. Subscribe here to get these essays delivered to your inbox, along with behind-the-scenes commentary and curated links on distributed systems, technology, and other curiosities. Generating vector embeddings for semantic search locallyThis is an external post of mine. Click here if you are not redirected. |