87

A migration is a product with metrics, not a task with a deadline

#

A zero-downtime migration (ID scheme, storage format, gitflow → trunk, platform) is not a “switch-over” but a pipeline: dual-run (both systems running) → shadow/compare (the new one gets a copy of traffic, discrepancies are measured) → gradual cutover with reversible routing → a coexistence period → contract (tearing down the old one). The progress metric is public (% of traffic / services / records on the new path), and — the most ignored part — the migration has an owner until the end: a migration that is 90% done is worse than one never started, because now there are two systems forever.

Code example (fragment)
Go · shadow-comparefragment
// ANTIPATTERN: a goroutine per request — unbounded concurrency,
// context cancelled right after the client reply, no timeout, no recover
go func() {
    nu, nerr := m.next.Get(ctx, id)          // ctx is already cancelled
    m.compare.Record(id, old, err, nu, nerr) // a panic here kills the process
}()

// CORRECT: the new implementation gets traffic but no vote —
// via a bounded queue, with its own context and timeout
func (m *Migrator) Get(ctx context.Context, id UserID) (User, error) {
    old, err := m.legacy.Get(ctx, id)
    m.shadow.Offer(job{id, old, err}) // non-blocking: if the queue is full,
    return old, err                   // the sample is dropped, client doesn't wait
}   // old one is still the source of truth; cutover when diff-rate < threshold
    // for N days straight, and that number is written down BEFORE the start

func (m *Migrator) shadowWorker() {
    for j := range m.shadow.Jobs() {
        func() {
            defer func() { _ = recover() }() // a worker panic doesn't crash the process
            ctx, cancel := context.WithTimeout(m.shadowBase, 200*time.Millisecond)
            defer cancel()
            nu, nerr := m.next.Get(ctx, j.id)
            m.compare.Record(j.id, redact(j.old), j.err, redact(nu), nerr)
        }()
    }
}

Honest assessment. Organizational migrations (gitflow → trunk, monorepo, platform) follow the same mechanics as technical ones: dual-run = both models are allowed, cutover = new services only on the new one, contract = the old one is switched off on a date announced in advance. “Voluntary and open-ended” is not a migration but a permanent doubling of maintenance. The cancellation criterion is also written before the start: a migration without a “when do we roll back” condition is a sunk-cost trap at production scale.

What the code does not show: shadowBase is not a context derived from the request but its own, carrying only the permitted correlation attributes and not a single user attribute; the shadow path needs a circuit breaker, otherwise degradation of the new system starts eating the old one’s resources; and the queue must have a dropped-samples metric — silently lost shadow traffic turns diff-rate into a number that measures itself.

Last technical review: 2026-08-04 · Reference · 112 principles