Before learning how to migrate, understand why it becomes necessary.
Systems are often built with tools that are fast and convenient early on, but those same tools can become liabilities as requirements evolve.
In this case:
The importance of data can outgrow the durability of the store holding it.
Ask yourself when designing systems:
| Data Type | Consequence of Loss | Appropriate Store |
|---|---|---|
| Auth tokens | Rebuilt in ~10 minutes | Cache is acceptable |
| Container status | Rebuildable | Cache is acceptable |
| Billing mappings | Permanently lost revenue | Needs durable storage |
A naive migration looks like this:
1. Stop the system
2. Copy all data
3. Switch to new database
4. Restart
This works for systems that can tolerate downtime. Most production systems cannot.
In this case:
Live systems require live migrations — the migration must happen while the system keeps running.
This constraint forces you into a more careful, phased approach.
This is one of the most important conceptual steps.
Map your old data structures directly to the new database.
Start from what the code actually needs to do, then design the schema to serve those needs.
The team listed every operation the system performed:
Old Redis model (data structures implied behavior):
- Tokens → set + sorted set
- Statuses → three separate sorted sets
- Billing mapping → string
New DynamoDB model (explicit keys and indexes):
type ContainerRecord = {
warmPoolId: string // partition key
containerId: string // sort key
token: string // stored as a hash (never exposed raw)
status: 'pending' | 'polling' | 'building'
expiresAt: number // single expiry field
}
| Old (Redis) | New (DynamoDB) | Reason |
|---|---|---|
| 3 sorted sets for status | 1 status field | Status is a property of the container, not a separate structure |
| Token in a set | Token as hashed field on record | Security + simpler access |
| Separate expiry per set | Single expiresAt field | One record, one expiry |
Schema design should follow access patterns, not the shape of the old system.
When migrating, treat it as an opportunity to redesign, not just translate.
This is the operational heart of a safe live migration.
Phase 1: Redis Only
└── Baseline established. Normal behavior recorded.
Phase 2: Dual Writes
└── Write to BOTH Redis and DynamoDB
└── Redis = source of truth
└── DynamoDB failures = logged, not fatal
└── Rollback: stop writing to DynamoDB
Phase 3: Shadow Reads
└── Read from BOTH stores
└── Compare results
└── Mismatches = investigated, not ignored
└── Rollback: stop reading from DynamoDB
Phase 4: DynamoDB Primary
└── DynamoDB = source of truth
└── Redis writes kept as backup
└── Rollback: flip reads back to Redis
Phase 5: DynamoDB Only
└── Redis writes removed
└── No easy rollback (but tokens expire in ~10 min anyway)
Each phase answers a specific question before you commit further:
| Phase | Question Being Answered |
|---|---|
| Dual Writes | Does DynamoDB accept all writes correctly? |
| Shadow Reads | Do both stores agree on stored values? |
| DynamoDB Primary | Does the system behave correctly reading from DynamoDB? |
| DynamoDB Only | Is Redis dependency fully removed? |
Each phase should have a rollback plan and a clear signal (dashboards, metrics) that tells you when it is safe to advance.
Never advance based on assumption. Advance based on evidence.
Shadow reads are a powerful technique worth understanding deeply.
Tests verify behavior in controlled conditions. Shadow mode verifies behavior under real production traffic, including:
The team monitored:
Shadow mode surfaces bugs that tests cannot, because production traffic is more complex than any test suite.
Every mismatch is a bug to fix before advancing, not a number to explain away.
This is where the migration became genuinely difficult, and where the deepest lesson lives.
The supply loop (which refills the warm pool) made hundreds of database reads per run. Nobody wrote this down as a requirement. It just worked because Redis was fast enough.
Loop as designed (implicit assumption: reads are ~1ms):
check → create → check → create → check → create → ...
(one read before every single container)
When DynamoDB replaced Redis:
The assumption was never documented. It was embedded in the design of the loop itself. The system worked, so no one questioned it.
This is called an implicit performance dependency — the code depends on a specific performance characteristic of a component, but that dependency is never stated.
Ask these questions during any migration:
The hardest part of migration is not moving the data. It is finding the assumptions built on top of the old system.
Once the hidden assumption was found, there were two options:
Check state once per N containers, where N is derived from the latency ratio.
Batch design:
check → create, create, create, ... (17 at a time) → check → ...
Why they rejected it:
17 was derived from a measured latency ratioRemove the serial dependency entirely. Let pool supply calls run concurrently.
Concurrent design:
Pool A: check → create
Pool B: check → create ← all running at the same time
Pool C: check → create
Why this is better:
When a dependency changes, redesign to remove the dependency rather than compensate for it.
Compensation preserves the fragility. Redesign eliminates it.
Here is the full framework this migration teaches:
LIVE DATABASE MIGRATION FRAMEWORK
1. JUSTIFY
└── Is the current store appropriate for the data's criticality?
2. DESIGN
└── List all access patterns first
└── Design schema to serve those patterns, not to mirror old schema
3. PHASE
└── Dual writes → Shadow reads → New primary → Old removed
└── Rollback available at every phase except the last
4. VALIDATE
└── Shadow mode catches what tests miss
└── Dashboards decide when to advance, not schedules
5. HUNT ASSUMPTIONS
└── What latency does the code silently depend on?
└── Where are the N+1 patterns?
└── What would 5x slower reads break?
6. REDESIGN
└── Remove dependencies rather than compensate for them
└── Document accepted tradeoffs explicitly
| Concept | One-Sentence Summary |
|---|---|
| Why migrate | Data criticality can outgrow storage durability |
| Why not stop-and-copy | Production systems cannot pause |
| Schema design | Follow access patterns, not old structure |
| Phased rollout | Each phase answers one question with a rollback ready |
| Shadow mode | Production traffic reveals what tests cannot |
| Hidden assumptions | The hardest migration work is finding what was never written down |
| Redesign vs compensate | Remove the dependency, do not patch around it |
The team's own summary said it best:
"Even a 1ms-to-15ms query time degradation on P90 could bring down our warm pool management logic."
Nobody knew that sentence was true until the migration forced them to find it.