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.
// 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.