Software Architecture · Legacy Systems

Switching the Write Path: Cutover, Rollback, and the Point of No Return

How to move writes to a replacement store without a window of two writers, what rollback actually requires after new-only data exists, and how to decide when reversal stops being possible.

Read traffic can be moved gradually and moved back cheaply. The replacement serves a request, produces an answer, and if the answer is wrong the router sends the next request elsewhere. Nothing persists. Write traffic is different in kind: a write that lands in the new store creates a fact that exists in exactly one place, and undoing the move means moving that fact back, not changing a routing rule.

That asymmetry is what makes cutover the highest-risk step in a migration and what makes the rollback plan the thing to design first. If rollback has not been demonstrated, the cutover is not reversible, whatever the runbook says.

Removing the two-writer window

The dangerous state is not cutover itself. It is any period in which both systems accept writes, because that is where the divergence described in dual writes appears, and it is the state most accidental cutovers produce: a routing change applied to some instances, a job that still connects to the old database, a cached flag that has not expired.

The removal has two parts, and both must be enforced at the storage layer rather than in application logic.

Drain the old path. Stop issuing new writes to the legacy system and wait for in-flight work to finish. Draining means the router stops routing new write requests to the old system, the connection pool is closed rather than merely reconfigured, and asynchronous workers are paused and allowed to complete their current batch. A job that holds a queued write in memory is not drained, which is why the pause must precede the check rather than follow it.

Make the old system refuse writes. Configuration flags are not enough, because a process that reads its flag at startup keeps writing with the old value. Revoking INSERT, UPDATE, and DELETE privileges from the application role, or setting the session or database to read-only, produces an error instead of a silent second writer. An error is the desired outcome: it is visible, attributable, and stops the divergence at the point of the attempt.

-- On the legacy database, after draining: writes fail loudly.
REVOKE INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA app FROM app_writer;
-- Any session that still tries to write gets:
-- ERROR: permission denied for table account

-- On the legacy primary, after draining: every standby must have replayed all WAL.
SELECT application_name,
       replay_lsn,
       pg_current_wal_lsn() = replay_lsn AS caught_up
  FROM pg_stat_replication;

The second query is the gate. Flipping while any standby still lags means the new store starts from a state that is missing the last interval of legacy writes. With a lagging standby the correct action is to wait, then re-drain, because new writes may have arrived during the wait.

The flip itself should be a single state change with a version, not a series of edits. Every process that routes or writes must observe the same version, and the change must be applied atomically with respect to new requests. A configuration reload that half-applies across a fleet leaves some instances writing to one store and some to the other, which is the two-writer state the drain was meant to eliminate. Where the fleet reloads asynchronously, the transition is safer if the legacy path fails closed: an instance that has not yet observed the new version still sends writes to a read-only legacy store and surfaces an error, rather than succeeding into the wrong place.

What has to be true before the flip

A cutover is a sequence of preconditions, and each one is checkable.

The destination must be able to apply every write the source accepts. This is a schema and semantics question, answered before the flip by routing a sample of writes through the new path in a shadow mode where the result is compared and discarded. The comparison is the differential test run against production shapes rather than fixtures.

The destination must be able to serve every read the source served. A write path that flips before the read path is ready leaves callers reading stale data from a store that no longer receives updates. Read cutover must therefore complete first, or the read path must be routed to the destination in the same change.

Sequences and identifiers must be advanced past the source’s high-water mark, or the first inserts collide with copied rows. Triggers, audit tables, and downstream exports that were attached to the legacy tables need an equivalent in the destination, or the organisation stops receiving data it did not know it depended on. Dependency mapping is what proves this list is complete.

Finally, the rollback must have been rehearsed on a copy of production data, including the reverse replication, and the rehearsal must have produced a measured recovery time. A plan that has not been executed is a hypothesis.

Rollback after writes have moved

There are two rollback designs, and they differ in what must be built beforehand.

Reverse replication. The destination streams its changes back to the legacy schema through a translation layer. Rollback then consists of stopping the forward stream, draining the destination, applying any remaining reverse events, and restoring write privileges on the legacy system. This works only while every destination write can be represented in the legacy schema. The moment a new-only field is populated or a new state is used, the reverse path needs a rule for it, and “drop the field” is a rule that loses data.

Replay from a log. If the destination’s changes are recorded in a durable, ordered log, rollback can replay that log into the legacy system in a batch after the fact. This is the same mechanism as the outbox, run backwards, and it has the same requirement: the events must carry enough information to reconstruct the legacy row, including fields the destination does not use.

Both designs share a property that is easy to miss: rollback is a second migration, with its own verification. Rows written during the cutover window must be reconciled after reversal exactly as they were before it, or the legacy system silently misses the interval.

Locating the point of no return

The point of no return is not a time. It is the first write that the legacy system cannot represent. Until that write occurs, rollback is a data operation with a known cost. After it, rollback requires either losing that information or extending the legacy schema, which reintroduces the change the migration was meant to remove.

Making this precise is a design task. The destination should be built so that the representable subset is a defined set of fields and states, and the migration should delay enabling non-representable capabilities until either the rollback window has closed by policy or the organisation accepts the loss. A feature flag that gates the new states, and a written record of which flags are on, converts an implicit deadline into an explicit decision.

Organisations frequently choose a time-based window instead, because a date is easier to communicate. That works if the window is short enough that the cost of replaying the interval is bounded and the reverse path has been tested. It fails when the window is long, because the reverse path decays: the old system keeps getting small changes from other teams, and the assumptions in the reverse translation stop holding.

The failure modes at the boundary

Two writers through a shared database. The new application and an unchanged legacy job both write the same table because they connect to the same database. Privilege revocation catches this only if the job uses a different role; otherwise the cutover has to include stopping the job, which the dependency map should have listed.

Write-behind buffers. Applications that batch writes in memory, or that flush on a timer, hold writes that were accepted before the drain. The drain must include a flush and a confirmation that the buffer is empty, not just a pause on intake.

Partial deployment. A router change applied to two of three instances leaves one instance routing to the legacy system. The cutover must be a single atomic configuration change observed by all instances, or the instances must be stopped before the change.

Clock-based ordering after the flip. Events produced by the destination and consumed by downstream systems carry the destination’s timestamps. Consumers that compare timestamps across the boundary can see apparent inversions when the two systems’ clocks differ. Ordering should come from sequence numbers, not wall-clock time.

The rollback that is never declared over. Keeping reverse replication indefinitely doubles the write cost and keeps the old schema alive. The window needs an owner and an end date, and the end should coincide with the deletion of the legacy write path, which is where retiring the legacy component begins.

References

  1. Continuous Delivery — Jez Humble and David Farley, Addison-Wesley, 2010
  2. PostgreSQL Documentation — Write-Ahead Logging — PostgreSQL Global Development Group
  3. Site Reliability Engineering — Beyer, Jones, Petoff, and Murphy, O'Reilly, 2016