Reading your own writes  / field guide
Practitioner field guide · 2026-09-24

Your write hasn't happened here yet

How production systems give a client back its own write when reads are served by replicas that trail the primary. Reconstructed from GitLab's public incident record, the source and issue trackers of Rails, MediaWiki, Vitess, ProxySQL, MaxScale, MongoDB, CockroachDB, Cloudflare D1 and LiteFS, and ten years of PostgreSQL commits. After reading, you can pick a read-your-writes mechanism deliberately, size its escape hatch, and know which two failure modes to instrument for, because neither raises an alarm on its own.

29 primary artefacts 15 organisations 4 postmortems Evidence through Sep 2026 Read: ~20 min
01

The territory

A system accepts a change, confirms it, and then answers the very next question from a copy that has not yet heard about the change. Every architecture that scales reads by replicating data owns this problem; this page maps how real systems answer it, and how the answers fail.

37h 12m
GitLab.com severity-2 incident whose only symptom was stale reads: CI pipelines stuck in running
2 s
Rails' shipped answer: after any write, the whole browser session reads the primary for two seconds
Price of a strongly consistent DynamoDB read versus the eventually consistent default
3 attempts
Landings of a wait-for-replication primitive in PostgreSQL core: reverted 2020, reverted 2024, shipped for v19

Start with the incident, because it shows the stakes precisely. On 7 October 2024 at 20:50 UTC, GitLab.com pipelines began sticking permanently in the running state. Nothing errored. No availability or latency graph moved in a way that pointed anywhere. The incident review reports that a refactor had caused Sidekiq middleware to route "certain queries to a replica db that should have been sent to the primary db, causing those queries to see slightly stale data (missing very recently committed transactions)". A worker asked a replica whether a pipeline needed processing a few milliseconds after the primary recorded that it did. The replica said no. The worker exited, no retry was scheduled, and the pipeline stayed "running" forever. It took 37 hours and several premature "mitigated" declarations to find, because a stale read is invisible to every standard signal: the query succeeded, quickly, with well-formed rows.

That is the shape of the whole territory. Replication lag is usually well under a second (AWS states DynamoDB replicas converge "usually within one second or less"; GitLab's health thresholds are 8 MB or 60 seconds of lag before a replica is even pulled from the pool). The engineering effort documented on this page exists for the tail beyond that, and, more importantly, for the small class of reads where staleness does not degrade a page view but wedges a state machine: workflow engines, saga steps, webhooks, anything that decides not to act because it cannot yet see that it should.

The surprise in this dig

Every layer of the stack has independently converged on the same four-part machine: mint a position token at commit, carry it with the client, route reads to a copy that has caught up to the token, and keep a bounded escape hatch for when none has. Wikimedia's ChronologyProtector, GitLab's Redis sticking, MongoDB sessions, Cloudflare D1 bookmarks and Fly.io's LiteFS cookie are the same design with different spellings. Meanwhile the database that most of them sit on took three landing attempts across six visible years of commits to ship the primitive underneath (WAIT FOR, PostgreSQL 19), and it was still receiving recovery-deadlock fixes twelve days before this page was researched. The concept is one sentence; the mechanism is genuinely hard at every layer.

Figure 1 · Where the machinery lives

A write is accepted
by the primary

Which layer promises
the writer can see it?

Web framework
Rails: 2 s timer per session
GitLab: LSN sticking in Redis

Job queue
GitLab Sidekiq: LSN travels
inside the job payload

SQL proxy
MaxScale causal_reads
ProxySQL GTID tracking

Driver or client SDK
MongoDB afterClusterTime
D1 session bookmarks, LiteFS cookie

Database engine
DynamoDB ConsistentRead
CockroachDB staleness bounds
PostgreSQL 19 WAIT FOR

A write is accepted
by the primary

Which layer promises
the writer can see it?

Web framework
Rails: 2 s timer per session
GitLab: LSN sticking in Redis

Job queue
GitLab Sidekiq: LSN travels
inside the job payload

SQL proxy
MaxScale causal_reads
ProxySQL GTID tracking

Driver or client SDK
MongoDB afterClusterTime
D1 session bookmarks, LiteFS cookie

Database engine
DynamoDB ConsistentRead
CockroachDB staleness bounds
PostgreSQL 19 WAIT FOR

The same guarantee is sold at five different layers, and the choice of layer is the first real decision. Sources: the implementations linked in the evidence wall.
Diagram source

Scope. This guide covers the read path of single-writer, asynchronously replicated systems: one primary, replicas that trail it, and the question of who may read where. It deliberately does not cover multi-primary writes and conflict resolution, cache-tier staleness and invalidation (a different dig in this series covers cache miss protocols), or geo-partitioned data placement. The vocabulary is Terry et al.'s 1994 "session guarantees" paper, which named read-your-writes; that paper and the later measurement literature were not reachable under this session's network policy, so everything below is built from incident reviews, source code, RFCs and issue threads that were fetched and are linked.

02

How it is actually built

Across every implementation in the evidence, the mechanism decomposes into the same four parts. What varies is the spelling of the token, where it is carried, and what happens when the wait runs out.

Figure 2 · The four-part machine

1 write

2 commit position
LSN, GTID, opTime, bookmark

3 carried in cookie, session,
store key or job payload

yes

not yet

caught up

timeout

asynchronous replication: the lag

Client

Primary

Token

Client's
next read

Replica caught up
to the token?

Replica

Bounded wait
MASTER_GTID_WAIT,
WAIT FOR LSN, poll

Escape hatch:
primary, error,
or defer

1 write

2 commit position
LSN, GTID, opTime, bookmark

3 carried in cookie, session,
store key or job payload

yes

not yet

caught up

timeout

asynchronous replication: the lag

Client

Primary

Token

Client's
next read

Replica caught up
to the token?

Replica

Bounded wait
MASTER_GTID_WAIT,
WAIT FOR LSN, poll

Escape hatch:
primary, error,
or defer

The dashed arrow is the lag everything else exists to hide. Reconstructed from ChronologyProtector, GitLab's sticking module, the Vitess read-after-write RFC and MaxScale's causal_reads.
Diagram source

1 · The token: a position, not a timestamp

Every serious implementation records where the primary was when the write committed: a PostgreSQL LSN (GitLab stores the raw 0/16B3A78 form and compares it in a Redis Lua script), a MySQL GTID set (MaxScale, ProxySQL, the Vitess RFC), MongoDB's operationTime, D1's opaque bookmark, LiteFS's transaction ID. The degenerate token is a clock reading: Rails stores session[:last_write] and compares it to a two-second window. A timestamp is cheap because the database does not have to participate; it is also a bet, not a guarantee, and section 04 shows what the bet costs when it loses.

2 · The carrier: the token follows the causality

MediaWiki puts the token reference in a cookie (cpPosIndex=<posIndex>@<write time>#<clientId>) so it survives changing IPs and data centres; the position itself sits in a dc-local store that must answer "within the order of one millisecond". GitLab keys Redis by user. MongoDB and D1 put it in a driver session object and make the application carry it between requests (D1's example ships it in an x-d1-bookmark HTTP header). The most instructive variant is GitLab's job queue: the LSN is written into the job payload, because for asynchronous work the reader is not the user's next request but a worker seconds later on another machine. The carrier must follow whatever carries the causality.

3 · The router, fed by a health filter

Routing is only as good as the pool it routes into. GitLab drops a replica from the candidate set at 8 MB or 60 seconds of measured lag (defaults; production tightens the latter to 30). Vitess vtgates "avoid serving data from instances that are lagging beyond X seconds". GitHub built a separate raft-replicated service, freno, whose lag measurements throttle writers so replicas stay valid for readers; its README states plainly that it exists for "mitigating write-then-read pains of master reads". The router then answers one question per read: is there a replica at or past the token? If yes, use it; GitLab "reverts to using replicas as soon as they have caught up, rather than always waiting the full 30 seconds".

4 · The wait, and the escape hatch behind it

When no replica has caught up, someone waits: MASTER_GTID_WAIT under MaxScale (budget: causal_reads_timeout, default 10 s), WAIT_FOR_EXECUTED_GTID_SET in the Vitess design, a 1 ms polling loop with a 5 s ceiling in LiteFS's proxy, PostgreSQL 19's WAIT FOR on the standby itself. The interesting design content is entirely in what happens at timeout. MaxScale retries on the primary. GitLab's Sidekiq defers: the job is retried later, and only promoted to the primary on the second miss. The Vitess RFC argues for failing the query instead, "preventing cascading load during system stress". All three answers are defensible; section 03 gives the condition that picks between them.

Two implementation details deserve attention because they mark the difference between a design sketch and a production system. First, expiry: every token dies young. Rails, two seconds. LiteFS's cookie, five minutes. GitLab's Redis keys, 30 seconds. MediaWiki's cookie, "about ten seconds", sized explicitly to cover "the cross-dc latency for those further away in any secondary DCs". An immortal token would pin every past writer to the primary forever and quietly convert the read-scaling tier into decoration; the fast modes of MaxScale carry exactly that warning, that under sustained writes "the traffic will end up being routed almost exclusively to the primary server". Second, the sticking store is itself a replicated data problem one level down. MediaWiki's doc block spells out the requirements: local reads must see local writes immediately, loss is survivable but visible, and the failure mode is honest: "users may be temporary confused as they observe their own actions as not being immediately reflected." GitLab compares LSNs inside an atomic Lua script so two racing requests cannot regress the stored position. When the 2024 incident broke this layer, nothing crashed; the machine kept running with the token pipeline severed.

03

The decisions that matter

Four forks recur across the record. Each block gives the observed choices and the condition that flips the answer.

Do you track time, or track position?

Chosen
  • Rails ships a 2-second timer per session; GitHub ran the same idea at 5 seconds plus "a curcuit breaker that checks the replication delay" (eileencodes, in the 2019 PR that upstreamed it).
  • GitLab, MediaWiki, MongoDB, D1, LiteFS all track positions.
Rejected
  • Position tracking rejected by Rails core as a default: it needs database cooperation and per-adapter machinery; the timer needs neither.
  • Timers rejected by GitLab and Wikimedia, whose position stores exist precisely because a fixed window is wrong at both ends.
Flips when
  • A stale read wedges a workflow rather than blemishing a page view, or your lag tail exceeds the window. Then the timer stops being an engineering choice and becomes a bet you cannot size. If all staleness costs you is a comment appearing late, the timer is the right amount of machinery.

On timeout, do you go to the primary, fail, or defer?

Chosen
  • MaxScale: the read "will be retried on the primary". Rails and GitLab web requests: primary.
  • GitLab Sidekiq: defer, retry the job once, then primary.
Rejected
  • The Vitess RFC recommends the opposite of everyone's default: fail the query rather than escalate, to prevent "cascading load during system stress"; the 2019 user issue behind it had already warned that blocking waits risk "exhaustion of resources if all queries got stuck waiting".
Flips when
  • Lag is correlated with load. Uncorrelated lag (one replica rebuilding) makes primary-fallback safe. When lag comes from overload, fallback aims the entire read fleet at the one node that is already the bottleneck; that is the October 2025 GitLab sev-1, twice. If you keep fallback, cap it with a budget or breaker; if you cannot cap it, prefer failing or deferring.

Whose writes must the reader see: their own, or everybody's?

Chosen
  • Session scope nearly everywhere: MaxScale local, MongoDB causal sessions, D1 sessions, ChronologyProtector's per-client positions.
Rejected
  • Cluster scope exists but is priced to discourage: MaxScale universal fetches @@gtid_binlog_pos from the primary before every read, so "the latency of any given SELECT statement increases by roughly twice the network latency". etcd is the deliberate exception: linearizable by default, because a coordination store's whole product is agreement.
Flips when
  • The reader is not the writer: CI job B reads what job A wrote, a webhook consumer reads what the request handler wrote. Session tokens do not span actors. Either the token travels with the work (GitLab's LSN-in-job-payload), or those specific reads go to the primary, or you pay the cluster-scope tax on everything.

The fourth fork is where the primitive should live, and the record here is the surprise this dig leads with. MySQL has shipped a session-scoped wait since 5.7 (WAIT_FOR_EXECUTED_GTID_SET, which every proxy above builds on). PostgreSQL's commit history tells a harder story: an implementation of "waiting for given lsn at transaction start" was reverted in April 2020; pg_wal_replay_wait() was committed on 2 April 2024 for version 17 and fully reverted nine days later "per review by Heikki Linnakangas" (version 17 shipped without the file); the WAIT FOR command finally landed on 5 November 2025 for version 19, and the fix "Prevent WAIT FOR LSN from deadlocking recovery on held locks" is dated 12 September 2026. The committer's thread dates the effort to 2016, which this session could not verify directly; the six years of visible history are enough to make the point. A wait primitive interacts with snapshots, locks held across the wait, and recovery itself. If it takes the database this long to get right, an application-layer version that polls and compares positions (GitLab's, MediaWiki's, LiteFS's) is not a hack to be embarrassed about; it is the pattern that shipped, a decade ahead.

Figure 3 · Six visible years of a one-sentence primitive

2020April, animplementation ofwaiting for a givenLSN at transactionstart is reverted2024April 2,pg_wal_replay_wait()is committed for v17April 11, fully revertedafter review. v17 shipswithout it2025November 5, theWAIT FOR commandis committed for v192026option, timeline andoverflow fixes landthrough the yearSeptember 12, a fixprevents WAIT FORLSN deadlockingrecoveryThe wait-for-replication primitive in PostgreSQL core
2020April, animplementation ofwaiting for a givenLSN at transactionstart is reverted2024April 2,pg_wal_replay_wait()is committed for v17April 11, fully revertedafter review. v17 shipswithout it2025November 5, theWAIT FOR commandis committed for v192026option, timeline andoverflow fixes landthrough the yearSeptember 12, a fixprevents WAIT FORLSN deadlockingrecoveryThe wait-for-replication primitive in PostgreSQL core
Dates from the PostgreSQL commit record (wait.c history, 06c418e, 772faaf, e0c160c).
Diagram source
DecisionCommon choiceAlternativeBecauseEvidence
Token kindReplication position (LSN/GTID/opTime/bookmark)Wall-clock window (2 s / 5 s)Positions are exact; clocks are cheap and need no DB cooperationGitLab sticking.rb, Rails PR #35073
Token carrierCookie or driver sessionServer-side store keyed by userCookies survive DC and IP changes; stores need ~1 ms answers on every requestChronologyProtector, MongoDB spec
Async workToken travels in the job payloadJobs always read the primaryReplica is "guaranteed to be caught up to the point at which the job was enqueued", else retry or primaryGitLab worker attributes
Timeout behaviourRetry on primary (bounded)Fail the read, or defer the workFallback is safe only while lag is uncorrelated with loadMaxScale, Vitess RFC, GitLab IR Oct 2025
Default directionWeak by default, strong opt-in (DynamoDB ConsistentRead, 2× cost)Strong by default, weak opt-out (etcd; CockroachDB staleness bounds)Follows what the product sells: throughput versus agreementDynamoDB guide, etcd guarantees, CRDB RFC
Per-callsite choiceForced and linted (every GitLab worker declares data_consistency)A global default others silently inheritSilent inheritance is how the wrong reads end up on replicasGitLab worker attributes

Figure 4 · Choosing a mechanism

page view

workflow or state machine

yes

no: jobs, webhooks,
downstream services

yes

no

Does a stale read wedge a workflow,
or only blemish a page view?

Use the timer window.
Rails default, expire in seconds.
Stop here.

Is the reader the writer?
Same user, same session?

Per-client position token:
LSN, GTID, opTime or bookmark,
with a short expiry

Send the token with the work,
or pin those reads to the primary

If every replica lags at once,
can the primary absorb all reads?

Timeout falls back to primary,
capped by a budget or breaker

Timeout fails the read
or defers the work.
Protect the primary.

page view

workflow or state machine

yes

no: jobs, webhooks,
downstream services

yes

no

Does a stale read wedge a workflow,
or only blemish a page view?

Use the timer window.
Rails default, expire in seconds.
Stop here.

Is the reader the writer?
Same user, same session?

Per-client position token:
LSN, GTID, opTime or bookmark,
with a short expiry

Send the token with the work,
or pin those reads to the primary

If every replica lags at once,
can the primary absorb all reads?

Timeout falls back to primary,
capped by a budget or breaker

Timeout fails the read
or defers the work.
Protect the primary.

Terminal boxes are actions. The tree compresses the three decision blocks above; the load-correlation question at the bottom is the one most designs skip.
Diagram source
04

What broke in production

Four published incidents, four distinct failure classes: the routing rule breaks silently, the escape hatch amplifies, the health signal lies, and the lag itself pulls an operator into danger. None of the four announced itself as a consistency problem.

Figure 5 · Anatomy of the silent class: GitLab, October 2024

Sidekiq workerReplicaPrimaryCI jobSidekiq workerReplicaPrimaryCI jobreplication in flightno retry scheduled. Pipeline shows"running" indefinitely, with zero errors anywherefinal job status writtenpipeline needs processing?(misrouted read)no, a job still looks unfinishedexits, nothing to do
Sidekiq workerReplicaPrimaryCI jobSidekiq workerReplicaPrimaryCI jobreplication in flightno retry scheduled. Pipeline shows"running" indefinitely, with zero errors anywherefinal job status writtenpipeline needs processing?(misrouted read)no, a job still looks unfinishedexits, nothing to do
The read succeeds, fast and well-formed; the damage is the action the worker does not take. From the incident review.
Diagram source
Postmortem

The routing rule breaks and nothing alarms

AssumptionReads that declared they need the primary (data_consistency: always) reach the primary.
What happenedA session-handling refactor ("sc1-session-map") broke the sticking layer; Sidekiq middleware sent primary-only reads to replicas, and PipelineProcessWorker saw pre-write state.
Blast radiusSeverity 2, 2024-10-07 20:50 to 2024-10-09 10:02 UTC (37 h 12 m); an estimated 150 k Sidekiq jobs affected; incident closed as mitigated and reopened repeatedly.
FixStraight revert (MR 168630) plus a feature flag to stop new pipelines being wedged; root cause found via a per-worker replica-read metric.
Design ruleRead-your-writes machinery fails silent, so monitor the invariant, not the symptom: alert when a primary-declared caller is observed reading a replica. GitLab found the bug the moment someone graphed exactly that.
Postmortem

The escape hatch amplifies the overload

AssumptionIf replicas get slow, the primary can carry the read traffic for a while.
What happenedOne user's scheduled pipeline sent ~4,000 requests/min to an endpoint whose query joins 16 tables at 7-8 s of CPU each. Replicas saturated, lag rose, and "Rails database load balancing detected slow replica responses and shifted traffic to the primary node", exhausting PgBouncer pools site-wide.
Blast radiusTwo severity-1 outages two days apart (2025-10-07 and 2025-10-09); 86% and 89% of all traffic dropped during the 15-18 minute impact windows.
FixEndpoint-specific and cost-aware rate limiting, query optimisation, and circuit breakers for pathological patterns.
Design ruleGitLab's own words: "When replicas fail health checks, shifting all traffic to the primary can accelerate rather than mitigate incidents." Fallback-to-primary must be budgeted, because lag caused by load is precisely the case where fallback is wrong.
Source

The health signal lies about staleness

AssumptionA replica passing health checks is within the configured staleness bound.
What happenedWith the replication source unreachable, MySQL reports seconds_behind_master as NULL; Vitess coerced NULL to 0, so a replica that could not replicate at all looked perfectly caught up and kept serving.
Blast radiusReported by a maintainer as "replicas serving VERY stale reads that can in turn cause a cascade of downstream issues"; the -unhealthy_threshold staleness bound was silently ineffective.
FixPR #9308: stop conflating "unknown lag" with "no lag".
Design ruleTreat unknown lag as infinite lag. Any coercion of NULL, timeout or error into zero converts your staleness bound into a comment.
Postmortem

The lag pulls a human into the blast zone

AssumptionReplication lag is a capacity nuisance handled by runbook, off the critical path.
What happenedA spam-driven load spike pushed GitLab.com's standby behind until "the replication failed as WAL segments needed by the secondary were already removed from the primary". Recovery required wiping the secondary's data directory and re-syncing; under pressure, late at night, the wipe command was run on the primary.
Blast radiusSix hours of production data lost (2017-01-31 17:20 to 00:00 UTC): roughly 5,000 projects, 5,000 comments and 700 new user accounts; of five backup mechanisms, one worked.
FixWAL archiving, tested restores, and procedural guards around destructive recovery steps.
Design ruleThe lag-repair path is part of the consistency design. Size WAL retention so lag does not force a full re-sync, and make the destructive step structurally unable to target the primary.

Figure 6 · The amplification loop: GitLab, October 2025

read load now competes with
the WAL the replicas need

Expensive query floods replicas
4,000 req/min, 7-8 s CPU each

Replica CPU saturates

Replication lag rises

Load balancer marks replicas slow,
shifts reads to the primary

Primary saturates,
PgBouncer pools exhaust

Site-wide timeouts,
86-89% of traffic dropped

read load now competes with
the WAL the replicas need

Expensive query floods replicas
4,000 req/min, 7-8 s CPU each

Replica CPU saturates

Replication lag rises

Load balancer marks replicas slow,
shifts reads to the primary

Primary saturates,
PgBouncer pools exhaust

Site-wide timeouts,
86-89% of traffic dropped

Steps as numbered in the incident review; the dashed edge is this guide's reading of why the loop closes, not a statement from the review.
Diagram source
05

Numbers you can plan against

The constants real systems chose, and what the incidents measured. Every row is linked; the provenance note below separates measured from claimed.

MetricValueSystemContextAs ofSource
Post-write primary window2 sRails defaultPer browser session, after any write2019-resolver.rb
Post-write primary window5 s + breakerGitHub (production)Plus a lag-checking circuit breaker that releases early2019Rails PR #35073
Sticky-read expiry30 sGitLabRedis key TTL; released earlier once replicas catch up2026sticking.rb
Replica eviction thresholds8 MB / 60 sGitLab defaultsmax_replication_difference / max_replication_lag_time2026admin doc
Client token window~10 sMediaWikiCookie lifetime sized to cover cross-DC replication2026ChronologyProtector
Position-store latency budget~1 msMediaWikiThe sticking store is on the path of nearly every request2026ChronologyProtector
Causal-read wait budget10 s defaultMaxScalecausal_reads_timeout, then retry on primary2026ReadWriteSplit doc
Cluster-scope read tax~2× network RTTMaxScale universalPrimary round-trip before every SELECT2026ReadWriteSplit doc
Replica poll / wait / cookie1 ms / 5 s / 5 minLiteFS proxyTXID polling interval, timeout, cookie expiry2026proxy_server.go
Strong-read price2× RCUDynamoDB1 vs 0.5 read units per 4 KB; not offered on GSIs2023capacity doc
Typical replication convergence≤ ~1 sDynamoDB (claimed)"usually within one second or less", vendor statement2023read consistency doc
Follower-read staleness floor~5 sCockroachDBClosed timestamp "roughly trails real time by five seconds"2018follower reads RFC
Stale-read incident duration37 h 12 mGitLab.comSeverity 2; ~150 k Sidekiq jobs affected2024-10incident review
Amplification outages86% / 89% trafficGitLab.comTwo severity 1s, 15-18 min impact each, two days apart2025-10incident review
Provenance

Measured: the GitLab incident figures and every constant read out of source code. Claimed: DynamoDB's sub-second convergence, a vendor statement with no published distribution behind it, and CockroachDB's five seconds, which is a design target from its own RFC. Unverifiable this session: the widely cited SOSP 2015 Facebook study, which per its abstract measured on the order of 0.0004% of sampled reads returning results a linearizable system would forbid; the paper's hosts were unreachable under this session's network policy, so treat that number as reported folklore until you fetch it yourself. No source in this corpus publishes a replication-lag distribution for a large fleet; that gap is real, and it is why every system above ships a health filter rather than an assumption.

06

The evidence wall

Every source behind this page, graded. This session's network policy allowed github.com and gitlab.com only, so the wall is repository record end to end: incident reviews, source files, RFCs, issues and in-repo vendor docs. Engineering-blog, paper and talk tiers are absent because their hosts were unreachable, not because the material was judged and cut; the hunt section says where those layers live.

Postmortem GitLab2024-10

Incident review: pipelines not completing

The definitive stale-read incident write-up: misrouted Sidekiq reads, 37 hours, repeated premature mitigation, root cause found only through a per-worker replica-read metric. Includes the linked incident issue (#18676) and the revert MR (168630).

Carry forwardStaleness has no alarm; graph the routing invariant itself.
gitlab.com/gitlab-com/gl-infra/production/-/issues/18681
Postmortem GitLab2025-10

Incident review: INC-4589 / INC-4641, database saturation

A six-step cascade from replica CPU saturation to site-wide pool exhaustion, twice in three days, with the load balancer's primary-fallback named as an amplifier in the lessons learned.

Carry forwardBudget the fallback path; lag caused by load makes fallback the wrong direction.
gitlab.com/gitlab-com/gl-infra/production/-/issues/20692
Postmortem GitLab2017-02

Postmortem of database outage of January 31

The canonical replication-lag disaster, read from the blog's markdown source at a pinned 2017 commit: lag broke replication entirely, and the manual re-sync procedure destroyed the primary's data directory.

Carry forwardThe lag-repair runbook is part of the consistency design; make the destructive step unable to hit the primary.
www-gitlab-com @ 026a78dd, source/posts/2017-02-10-postmortem...
Postmortem GitLab2024-10

Incident issue: pipelines not completing and stuck merge requests

The live incident record behind review #18681: three rounds of investigation, two red-herring subsystems, and the note where a graph of replica reads by data_consistency: always workers cracked the case.

Carry forwardDuring an incident, ask "who is reading replicas that should not be" early; it is cheap to graph.
gitlab.com/gitlab-com/gl-infra/production/-/issues/18676
Source GitLabmaster, 2026-09

load_balancing/sticking.rb

The production sticking layer: per-client LSN in Redis with a 30-second TTL, and Lua scripts that parse and compare LSNs atomically so racing requests cannot regress the stored position.

Carry forwardThe token store update is a compare-and-swap, not a write; get it atomic or lose positions under concurrency.
gitlab-org/gitlab lib/gitlab/database/load_balancing/sticking.rb
Vendor GitLabmaster, 2026-09

Database Load Balancing (administration doc)

The operational envelope around the code: 30-second primary sticking released early when replicas catch up, and lag thresholds (8 MB, 60 s) that evict replicas from the candidate pool.

Carry forwardRouting and health-filtering are one feature; document both numbers together.
doc/administration/postgresql/database_load_balancing.md
Vendor GitLabmaster, 2026-09

Sidekiq worker attributes: data_consistency

The asynchronous half of the design: jobs enqueue with the primary's LSN; :sticky and :delayed workers get replicas guaranteed caught up to enqueue time, or a retry, or the primary. Every worker must declare a mode, enforced by RuboCop.

Carry forwardMake consistency a required, linted, per-callsite declaration; defaults are how the wrong reads reach replicas.
doc/development/sidekiq/worker_attributes.md
Source Rails / GitHub2019-01

PR #35073: automatic database switching

The PR that upstreamed GitHub's approach into Rails: two-second post-write window per session, with the author noting GitHub's production variant runs five seconds plus a replication-delay circuit breaker, and a reviewer flagging the query cache as a remaining stale-read hole.

Carry forwardThe framework default is the crude version of what its authors run; read the PR thread for the missing parts.
github.com/rails/rails/pull/35073
Source Railsmain, 2026-09

database_selector resolver

SEND_TO_REPLICA_DELAY = 2.seconds, a timestamp in session[:last_write], and a comparison. The whole shipped mechanism is thirty lines.

Carry forwardKnow exactly how little the default does before trusting a workflow to it.
activerecord/.../database_selector/resolver.rb
Source OpenStreetMap2021-05 to 2026-03

PR #3201: use database replicas for read requests (closed unmerged)

A five-year attempt to adopt Rails' replica switching in a mature application, blocked by a framework migrations bug and dependency gaps, closed with "it's probably easier to start afresh, rather than rebase this PR." Its predecessor (#2634) has been open since 2020.

Carry forwardRetrofitting read-write splitting into a grown codebase is a project, not a config flag; budget it like one.
github.com/openstreetmap/openstreetmap-website/pull/3201
Source Wikimediamaster, 2026-09

ChronologyProtector.php

The oldest complete implementation in the corpus, documented like a design paper in its header: client-ID-keyed positions, a ten-second cookie window sized to cross-DC replication, a one-millisecond store budget, and an explicitly accepted degraded mode.

Carry forwardWrite the store's requirements and the failure behaviour down; this comment block is the best spec of the pattern anywhere.
wikimedia/mediawiki includes/libs/Rdbms/ChronologyProtector.php
ADR Vitess2020-10

RFC: Read After Write (#6843)

The complete GTID-based design: MySQL returns the GTID in the OK packet, VTGate wraps it, replicas run WAIT_FOR_EXECUTED_GTID_SET. Recommends failing on timeout rather than escalating to the primary. Open since 2020; the user ask (#4718) predates it by 18 months.

Carry forwardThe fail-on-timeout position is the road less taken; steal its reasoning when your primary cannot absorb fallback.
github.com/vitessio/vitess/issues/6843
Source Vitess2021-12

Issue #9307: NULL lag treated as zero

A maintainer-filed bug: with the source unreachable, seconds_behind_master is NULL, was coerced to 0, and unboundedly stale replicas passed health checks. Fixed in PR #9308.

Carry forwardUnknown lag is infinite lag; audit every place a lag probe's error path defaults to zero.
github.com/vitessio/vitess/issues/9307
Source ProxySQL2019-07

Issue #2134: causal reads on MariaDB/Galera

Why the proxy cannot always save you: ProxySQL's GTID causal reads require the server's session_track_gtids, which MariaDB did not provide. The guarantee is only as available as the weakest layer's cooperation.

Carry forwardBefore promising read-your-writes through a proxy, verify the specific server flavour exposes session GTID tracking.
github.com/sysown/proxysql/issues/2134
Vendor MariaDB24.02, 2026-09

MaxScale ReadWriteSplit: causal_reads

Seven modes spanning session, service and cluster causality, wait-based and route-based variants, a 10-second budget, primary retry on timeout, and honest warnings about the fast modes collapsing onto the primary under writes.

Carry forwardThis one enum is the whole decision space; use it as a checklist even off MariaDB.
MaxScale Documentation/Routers/ReadWriteSplit.md
ADR Cockroach Labs2018-06

RFC: Follower reads

The strong-by-default database's opt-out: closed timestamps trailing real time by about five seconds let historical reads run on followers, "away from foreground traffic".

Carry forwardIn a strongly consistent store the decision inverts: you choose which reads may be stale, with an explicit bound.
cockroachdb docs/RFCS/20180603_follower_reads.md
ADR Cockroach Labs2021-05

RFC: Bounded staleness reads

Generation two: the reader states a staleness budget (with_max_staleness) and the system minimises staleness inside it, "more tolerant to replication lag" than a fixed historical timestamp.

Carry forward"How stale may this read be" is a better API than "read at this time"; consider exposing the budget to callers.
cockroachdb docs/RFCS/20210519_bounded_staleness_reads.md
ADR MongoDBmaster, 2026-09

Driver specification: causal consistency

The token pipeline as a formal spec: every server response carries an operationTime; drivers must store it in the session and replay it as afterClusterTime on subsequent reads, without validating it.

Carry forwardIf you build a driver-level token, spec the MUSTs like this; the guarantee lives or dies on unconditional token propagation.
mongodb/specifications causal-consistency.md
Vendor Cloudflare2026-09

D1 read replication: Sessions API

The 2025 incarnation: replicas "may be arbitrarily out of date", sessions guarantee sequential consistency via bookmarks, and the application carries the bookmark between requests itself, shown as an x-d1-bookmark header.

Carry forwardEven fully managed platforms hand the carrier problem back to you; plan where the token rides in your protocol.
cloudflare-docs d1/best-practices/read-replication.mdx
Vendor AWSarchived 2023

DynamoDB: read consistency and capacity pricing

Weak-by-default with a priced opt-in: eventually consistent unless ConsistentRead is set; the strong read costs twice the capacity, is unavailable on global secondary indexes, and may return a 500 during network trouble.

Carry forwardStrong reads are a budget line and an availability trade, not a checkbox; price them into the design review.
awsdocs HowItWorks.ReadConsistency.md
Source GitHub2026-09

freno: cooperative replication-lag throttler

The write-side complement: a raft-replicated service that throttles bulk writers when replica lag crosses a threshold, keeping replicas valid for readers; its README names "mitigating write-then-read pains of master reads" as a use.

Carry forwardYou can also buy read-your-writes by slowing writers; for bulk and migration traffic it is often the cheapest lever.
github.com/github/freno
Source PostgreSQL2020-2026

The wait-primitive commit saga

Three landings: a 2020 revert, pg_wal_replay_wait() committed 2024-04-02 and reverted 2024-04-11 (v17 shipped without it), and WAIT FOR committed 2025-11-05 for v19, with a recovery-deadlock fix as late as 2026-09-12.

Carry forwardWaiting for replication interacts with locks, snapshots and recovery; whoever implements it, test those three interactions first.
postgres/postgres commits, src/backend/commands/wait.c
Source Fly.iomain, 2026-09

LiteFS proxy_server.go

The pattern reinvented at the edge for SQLite: a __txid cookie, replicas polled every millisecond up to five seconds, cookie expiring after five minutes, writes forwarded to the primary.

Carry forwardThe design costs about 200 lines when the token is a single integer; the hard part is everything around it.
superfly/litefs http/proxy_server.go
Vendor etcdv3.5 docs, 2026-09

etcd API guarantees

The counter-default for contrast: "etcd ensures linearizability for all other operations by default." A coordination store sells agreement, so it pays the read-time cost up front and lets callers opt down instead.

Carry forwardDefaults follow the product's promise; decide what yours is before copying anyone's default.
etcd-io/website learning/api_guarantees.md
07

Build a miniature, then productionise it

Six rungs from reproducing the anomaly to instrumenting the two silent failure modes. Two Postgres containers and any web framework are enough for all of them.

Reproduce the violation on demand

Stand up a primary and a streaming replica. Write a row, read it back from the replica in a tight loop, and log the misses. Then pause replay on the replica (SELECT pg_wal_replay_pause()) and watch every read go stale.

Done when: you can produce and clear a read-your-writes violation at will.  Teaches: the anomaly is a race you can win or lose deliberately, not weather.

Ship the timer, then break it

Add Rails-style switching: after a write, pin that session to the primary for two seconds. Now hold replay paused for ten seconds and measure the violation rate.

Done when: violations vanish under normal lag and return the moment lag exceeds the window.  Teaches: a timer is a bet on the lag distribution's tail, and you now know exactly when it loses.

Mint and carry a real token

After each write, capture pg_current_wal_insert_lsn() into a cookie. Before each replica read, compare against pg_last_wal_replay_lsn() and poll (or use WAIT FOR on PostgreSQL 19) with a one-second budget before giving up.

Done when: zero violations at any induced lag, and you can report added read latency at p50/p99.  Teaches: the full mint-carry-route-wait pipeline and what the wait costs.

Cap the escape hatch

On wait timeout, fall back to the primary, but through a counter that allows at most N fallback reads per second and fails the rest. Load-test with replay paused fleet-wide.

Done when: total primary read load stays bounded no matter how many clients time out.  Teaches: the October 2025 lesson, that an uncapped fallback is a self-aimed stampede.

Make the token follow the work

Add a job queue. Enqueue each job with the LSN captured at enqueue time; workers check the replica against the job's LSN and defer once (re-enqueue with delay) before promoting to the primary, GitLab's :delayed semantics.

Done when: a write-then-enqueue-then-read chain never observes pre-write state and touches the primary only on double misses.  Teaches: cross-actor causality, the case session tokens cannot cover.

Instrument the two silent failures

Break your own router (route one "must-primary" endpoint to replicas) and your own health probe (make the lag query return NULL, coerced to 0). Add the two alerts that catch both: primary-declared callers observed on replicas, and replicas whose lag is unknown rather than low.

Done when: both injected faults page you before any user-visible symptom.  Teaches: failure classes A and C from section 04, which no default dashboard shows.

08

Keep hunting

The queries that found this material, grouped by what they surface. The first group works even from a network position that can only reach the big code forges, which is how this page was built.

Incident record (public trackers)

  • site:gitlab.com gl-infra production "incident review" replication
  • gitlab.com/api/v4/projects/gitlab-com%2Fgl-infra%2Fproduction/issues?labels=incident-review&search=replication
  • "stale read" OR "stale data" incident review database
  • "load balancing" "shifted traffic to the primary"

Mechanism vocabulary (learned from sources)

  • "read-after-write" OR "read your writes" replica postmortem
  • "causal_reads" OR "MASTER_GTID_WAIT" site:github.com
  • "WAIT_FOR_EXECUTED_GTID_SET" proxy OR vtgate
  • "session_track_gtids" causal
  • "chronology protector" OR "cpPosIndex" mediawiki

The repository record

  • github.com/postgres/postgres/commits/master/src/backend/commands/wait.c
  • pg_wal_replay_wait revert pgsql-hackers
  • repo:vitessio/vitess "read after write" is:issue
  • "data_consistency" sticky delayed sidekiq site:gitlab.com
  • "seconds_behind_master" NULL healthcheck stale

Layers this session could not reach

  • Terry "session guarantees" 1994 Bayou "read your writes"
  • "existential consistency" facebook sosp 2015 measurement
  • "scaling memcache" nsdi 2013 "remote marker"
  • intitle:"read your writes" engineering blog replica lag
09

References

  1. GitLab, Incident Review: 2024-10-08: Pipelines not completing GitLab.com production tracker, October 2024. Checked 2026-09-24.
  2. GitLab, Incident: Pipelines not completing and Stuck Merge Requests GitLab.com production tracker, October 2024. Checked 2026-09-24.
  3. GitLab, MR 168630: Revert "Merge branch 'sc1-session-map' into 'master'" gitlab-org/gitlab, merged 2024-10-09. Checked 2026-09-24.
  4. GitLab, Incident Review: GitLab.com slow and not loading some elements (INC-4589 / INC-4641) GitLab.com production tracker, October 2025. Checked 2026-09-24.
  5. GitLab, Postmortem of database outage of January 31 (blog source, pinned commit) www-gitlab-com repository, 2017-02-10. Checked 2026-09-24.
  6. GitLab, lib/gitlab/database/load_balancing/sticking.rb gitlab-org/gitlab, master. Checked 2026-09-24.
  7. GitLab, Database Load Balancing administration documentation gitlab-org/gitlab, master. Checked 2026-09-24.
  8. GitLab, Sidekiq worker attributes: data_consistency gitlab-org/gitlab, master. Checked 2026-09-24.
  9. eileencodes, Rails PR #35073: Adds basic automatic database switching to Rails rails/rails, merged 2019-01-30. Checked 2026-09-24.
  10. Rails, database_selector resolver source rails/rails, main. Checked 2026-09-24.
  11. gravitystorm, OSM PR #3201: WIP: Use database replicas for read requests openstreetmap-website, 2021-05-19, closed unmerged 2026-03-18. Checked 2026-09-24.
  12. gravitystorm, OSM PR #2634: Use database replicas for read requests openstreetmap-website, opened 2020-05-27. Checked 2026-09-24.
  13. Wikimedia, ChronologyProtector.php (MediaWiki core, GitHub mirror) wikimedia/mediawiki, master. Checked 2026-09-24.
  14. harshit-gangal, Vitess RFC: Read After Write (#6843) vitessio/vitess, 2020-10-09, open. Checked 2026-09-24.
  15. inexplicable, Vitess issue #4718: causal consistent read support from vitess replica vitessio/vitess, 2019-03-12, open. Checked 2026-09-24.
  16. mattlord, Vitess issue #9307: vtgate serves queries from replicas when its source is unavailable vitessio/vitess, 2021-12-01, closed. Checked 2026-09-24.
  17. ProxySQL issue #2134: Causal Consistency Reads & Tracking GTID on MariaDB/Galera sysown/proxysql, 2019-07-12, closed. Checked 2026-09-24.
  18. MariaDB, MaxScale ReadWriteSplit router documentation (causal_reads) mariadb-corporation/MaxScale, 24.02 branch. Checked 2026-09-24.
  19. Cockroach Labs, RFC 20180603: Follower reads cockroachdb/cockroach, June 2018. Checked 2026-09-24.
  20. Cockroach Labs, RFC 20210519: Bounded staleness reads cockroachdb/cockroach, May 2021. Checked 2026-09-24.
  21. MongoDB, Driver specification: Causal Consistency mongodb/specifications, master. Checked 2026-09-24.
  22. Cloudflare, D1 read replication documentation (Sessions API) cloudflare/cloudflare-docs, production branch. Checked 2026-09-24.
  23. AWS, DynamoDB developer guide: Read consistency awsdocs (archived repository, 2023). Checked 2026-09-24.
  24. AWS, DynamoDB developer guide: Read/write capacity mode awsdocs (archived repository, 2023). Checked 2026-09-24.
  25. GitHub, freno: cooperative, highly available throttler service github/freno, master. Checked 2026-09-24.
  26. PostgreSQL, commit 06c418e: Implement pg_wal_replay_wait() stored procedure postgres/postgres, 2024-04-02. Checked 2026-09-24.
  27. PostgreSQL, commit 772faaf: Revert: Implement pg_wal_replay_wait() stored procedure postgres/postgres, 2024-04-11. Checked 2026-09-24.
  28. PostgreSQL, commit history of src/backend/commands/wait.c (WAIT FOR) postgres/postgres, 2025-11-05 onward. Checked 2026-09-24.
  29. PostgreSQL, commit e0c160c: Prevent WAIT FOR LSN from deadlocking recovery on held locks postgres/postgres, 2026-09-12. Checked 2026-09-24.
  30. Fly.io, LiteFS http/proxy_server.go superfly/litefs, main. Checked 2026-09-24.
  31. etcd, API guarantees (v3.5 documentation source) etcd-io/website, main. Checked 2026-09-24.